From 5b1bd0c837d7de83f49a8b87a74a4fb8bba2bd10 Mon Sep 17 00:00:00 2001 From: Robert Good Date: Sat, 31 Jan 2026 23:15:31 -0800 Subject: [PATCH 1/8] Initial commit --- .azure/modules/afd-azurefrontdoor.bicep | 31 + .azure/modules/api-appservice.bicep | 95 + .azure/modules/apim-apimanagement.bicep | 49 + .azure/modules/app-appservicevnet.bicep | 32 + .../appcs-appconfigurationsetting.bicep | 35 + .../modules/appcs-appconfigurationstore.bicep | 31 + .azure/modules/appi-applicationinsights.bicep | 39 + .azure/modules/bot-botservice.bicep | 76 + .../modules/cdn-contentdeliverynetwork.bicep | 28 + .azure/modules/cog-cognitiveservices.bicep | 21 + .azure/modules/cogtext-textanalytics.bicep | 24 + .azure/modules/cosmos-cosmosdb.bicep | 46 + .azure/modules/dgw-onpremdatagateway.bicep | 33 + .azure/modules/ftpcn-ftpapiconnection.bicep | 58 + .azure/modules/func-functionsapp.bicep | 125 + .azure/modules/hcn-hybridconnection.bicep | 17 + .azure/modules/hnbind-hostNameBinding.bicep | 41 + .azure/modules/kv-keyvault.bicep | 70 + .../modules/luis-languageunderstanding.bicep | 63 + .azure/modules/nsg-networksecuritygroup.bicep | 33 + .../nsgrule-networksecuritygrouprule.bicep | 26 + .azure/modules/o365cn-o365apiconnection.bicep | 28 + .azure/modules/plan-appserviceplan.bicep | 87 + .azure/modules/qna-qnamaker.bicep | 137 + .azure/modules/redis-rediscache.bicep | 76 + .azure/modules/relay-relaynamespace.bicep | 45 + .azure/modules/rg-resourcegroup.bicep | 19 + .azure/modules/sb-servicebus.bicep | 53 + .../modules/sent-loganalyticsworkspace.bicep | 45 + .../modules/sftpcn-sftpsshapiconnection.bicep | 76 + .../modules/sjc-schedulejobcollection.bicep | 80 + .../modules/snet-virtualnetworksubnet.bicep | 23 + .../spocn-sharepointonlineapiconnection.bicep | 34 + .azure/modules/sql-sqlserver.bicep | 53 + .azure/modules/sql-sqlserverdatabase.bicep | 95 + .azure/modules/sqlcn-sqldgwconnection.bicep | 82 + .azure/modules/sqldb-sqldatabase.bicep | 51 + .azure/modules/sqldb-sqlserverdatabase.bicep | 94 + .azure/modules/st-storageaccount.bicep | 73 + .azure/modules/stapp-staticwebapp.bicep | 40 + .../stblobcn-storageblobapiconnection.bicep | 37 + ...sttablecn-storagetablesapiconnection.bicep | 36 + .../modules/teamscn-teamsapiconnection.bicep | 28 + .azure/modules/vnet-virtualnetwork.bicep | 31 + .../vnetpeer-virtualnetworkpeering.bicep | 36 + .azure/modules/wcert-webcertificate.bicep | 65 + .azure/modules/web-appservice.bicep | 98 + .../modules/work-loganalyticsworkspace.bicep | 33 + .azure/scripts/System.psm1 | 1483 +++++ .../entra/New-EntraAppRegistrationSecret.ps1 | 97 + .../entra/New-EntraAppRegistrations.ps1 | 446 ++ .../scripts/entra/Set-ApiAppUserSecrets.ps1 | 78 + .../scripts/entra/Set-WebAppUserSecrets.ps1 | 84 + .../scripts/hub-and-spoke/New-PlatformHub.ps1 | 32 + .../hub-and-spoke/New-PlatformSpoke.ps1 | 27 + .../scripts/hub-and-spoke/New-VnetPeering.ps1 | 55 + .../landingzone-standalone-web-api-sql.bicep | 145 + ...zone-standalone-web-api-sql-dev.bicepparam | 29 + .github/copilot-instructions.md | 43 + .github/scripts/System.psm1 | 1500 +++++ .../cd/New-Github-Azure-Federation.ps1 | 70 + .github/scripts/ci/Get-Version.ps1 | 94 + .github/scripts/ci/Set-Version.ps1 | 101 + .../scripts/repo/New-GithubRepoBootstrap.ps1 | 254 + .github/scripts/repo/New-GithubSecret.ps1 | 69 + .../workflows/gtc-semker-standalone-iac.yml | 94 + .../gtc-semker-standalone-web-api-sql.yml | 325 ++ .gitignore | 54 +- LICENSE | 2 +- README.md | 434 +- data/.vscode/tasks.json | 17 + data/Admin/Drop Tables.sql | 45 + data/Chat/ChatMessages.sql | 0 data/Chat/ChatSessions.sql | 0 data/Chat/Multimedia.sql | 1 + data/Data.sqlproj | 19 + data/Identity.sql | 0 ...mework-Quick-start-Blazor-Side-by-Side.png | Bin 0 -> 180437 bytes ...tFramework-Quick-start-Blazor-Solution.png | Bin 0 -> 15066 bytes ...rk-Quick-start-Blazor-Startup-Projects.png | Bin 0 -> 49807 bytes src/.editorconfig | 88 + src/App.razor | 0 .../Abstractions/IActorResponse.cs | 9 + .../Abstractions/IActorsPlugin.cs | 7 + .../Abstractions/IChatMessagesPlugin.cs | 7 + .../Abstractions/IChatSessionsPlugin.cs | 7 + .../Abstractions/ISemanticKernelContext.cs | 25 + .../Abstractions/ISemanticPluginCompatible.cs | 8 + .../Abstractions/IUserInfoRequest.cs | 16 + src/Core.Application/Actor/ActorDto.cs | 34 + .../Actor/CreateActorCommand.cs | 57 + .../Actor/CreateActorCommandValidator.cs | 9 + .../Actor/DeleteActorByExternalIdCommand.cs | 30 + ...DeleteActorByExternalIdCommandValidator.cs | 8 + .../Actor/DeleteActorCommand.cs | 30 + .../Actor/DeleteActorCommandValidator.cs | 9 + .../Actor/GetActorChatSessionQuery.cs | 32 + .../GetActorChatSessionQueryValidator.cs | 10 + .../GetActorChatSessionsPaginatedQuery.cs | 34 + ...ctorChatSessionsPaginatedQueryValidator.cs | 22 + .../Actor/GetActorChatSessionsQuery.cs | 29 + .../GetActorChatSessionsQueryValidator.cs | 18 + src/Core.Application/Actor/GetActorQuery.cs | 29 + .../Actor/GetActorQueryValidator.cs | 9 + src/Core.Application/Actor/GetMyActorQuery.cs | 30 + .../Actor/GetMyActorQueryValidator.cs | 9 + .../Actor/SaveMyActorCommand.cs | 49 + .../Actor/SaveMyActorCommandValidator.cs | 10 + .../Actor/UpdateActorCommand.cs | 41 + .../Actor/UpdateActorCommandValidator.cs | 9 + .../Audio/CreateTextToAudioCommand.cs | 66 + .../CreateTextToAudioCommandValidator.cs | 9 + .../Audio/DeleteTextAudioCommand.cs | 30 + .../Audio/DeleteTextAudioCommandValidator.cs | 9 + .../Audio/GetTextAudioQuery.cs | 30 + .../Audio/GetTextAudioQueryValidator.cs | 9 + .../Audio/GetTextAudiosPaginatedQuery.cs | 30 + .../GetTextAudiosPaginatedQueryValidator.cs | 20 + .../Audio/GetTextAudiosQuery.cs | 26 + .../Audio/GetTextAudiosQueryValidator.cs | 16 + src/Core.Application/Audio/TextAudioDto.cs | 27 + .../ChatCompletion/ChatMessageDto.cs | 25 + .../ChatCompletion/ChatSessionDto.cs | 25 + .../CreateChatMessageCommand.cs | 104 + .../CreateChatMessageCommandValidator.cs | 10 + .../CreateChatSessionCommand.cs | 89 + .../CreateChatSessionCommandValidator.cs | 9 + .../DeleteChatSessionCommand.cs | 30 + .../DeleteChatSessionCommandValidator.cs | 9 + .../ChatCompletion/DeleteTextImageCommand.cs | 30 + .../DeleteTextImageCommandValidator.cs | 9 + .../ChatCompletion/GetChatMessageQuery.cs | 30 + .../GetChatMessageQueryValidator.cs | 9 + .../GetChatMessagesPaginatedQuery.cs | 30 + .../GetChatMessagesPaginatedQueryValidator.cs | 20 + .../ChatCompletion/GetChatMessagesQuery.cs | 26 + .../GetChatMessagesQueryValidator.cs | 16 + .../ChatCompletion/GetChatSessionQuery.cs | 30 + .../GetChatSessionQueryValidator.cs | 9 + .../GetChatSessionsPaginatedQuery.cs | 30 + .../GetChatSessionsPaginatedQueryValidator.cs | 20 + .../ChatCompletion/GetChatSessionsQuery.cs | 26 + .../GetChatSessionsQueryValidator.cs | 16 + .../ChatCompletion/GetMyChatSessionQuery.cs | 40 + .../GetMyChatSessionQueryValidator.cs | 10 + .../GetMyChatSessionsPaginatedQuery.cs | 40 + ...etMyChatSessionsPaginatedQueryValidator.cs | 22 + .../ChatCompletion/GetMyChatSessionsQuery.cs | 34 + .../GetMyChatSessionsQueryValidator.cs | 18 + .../ChatCompletion/PatchChatSessionCommand.cs | 44 + .../PatchChatSessionCommandValidator.cs | 9 + .../UpdateChatSessionCommand.cs | 33 + .../UpdateChatSessionCommandValidator.cs | 10 + .../Common/Behaviors/CustomLoggingBehavior.cs | 15 + .../Behaviors/CustomPerformanceBehavior.cs | 54 + .../CustomUnhandledExceptionBehavior.cs | 41 + .../Behaviors/CustomValidationBehavior.cs | 44 + .../Common/CustomLoggerExtensions.cs | 16 + .../Exceptions/CustomConflictException.cs | 23 + .../CustomForbiddenAccessException.cs | 23 + .../Exceptions/CustomNotFoundException.cs | 23 + .../Common/Mappings/MappingExtensions.cs | 13 + .../Common/Models/PaginatedList.cs | 21 + src/Core.Application/ConfigureServices.cs | 45 + src/Core.Application/Core.Application.csproj | 21 + src/Core.Application/GlobalUsings.cs | 4 + .../Image/CreateTextToImageCommand.cs | 57 + .../CreateTextToImageCommandValidator.cs | 9 + .../Image/GetTextImageQuery.cs | 30 + .../Image/GetTextImageQueryValidator.cs | 9 + .../Image/GetTextImagesPaginatedQuery.cs | 30 + .../GetTextImagesPaginatedQueryValidator.cs | 20 + .../Image/GetTextImagesQuery.cs | 26 + .../Image/GetTextImagesQueryValidator.cs | 16 + src/Core.Application/Image/TextImageDto.cs | 50 + .../TextGeneration/CreateTextPromptCommand.cs | 57 + .../CreateTextPromptCommandValidator.cs | 9 + .../TextGeneration/DeleteTextPromptCommand.cs | 30 + .../DeleteTextPromptCommandValidator.cs | 9 + .../TextGeneration/GetTextPromptQuery.cs | 30 + .../GetTextPromptQueryValidator.cs | 9 + .../GetTextPromptsPaginatedQuery.cs | 30 + .../GetTextPromptsPaginatedQueryValidator.cs | 20 + .../TextGeneration/GetTextPromptsQuery.cs | 26 + .../GetTextPromptsQueryValidator.cs | 16 + .../TextGeneration/TextPromptDto.cs | 23 + .../TextGeneration/TextResponseDto.cs | 23 + src/Core.Domain/Actor/ActorEntity.cs | 46 + src/Core.Domain/Audio/TextAudioEntity.cs | 47 + src/Core.Domain/Auth/IUserEntity.cs | 14 + src/Core.Domain/Auth/UserEntity.cs | 35 + .../ChatCompletion/ChatMessageEntity.cs | 23 + .../ChatCompletion/ChatMessageRoles.cs | 9 + .../ChatCompletion/ChatSessionEntity.cs | 33 + src/Core.Domain/Core.Domain.csproj | 17 + src/Core.Domain/GlobalUsings.cs | 0 src/Core.Domain/Image/TextImageEntity.cs | 76 + .../TextGeneration/TextPromptEntity.cs | 24 + .../TextGeneration/TextResponseEntity.cs | 22 + src/Directory.Build.props | 38 + src/Directory.Build.targets | 13 + src/Get-CodeCoverage.ps1 | 64 + src/Goodtocode.AgentFramework.Blazor.sln | 61 + src/Goodtocode.AgentFramework.WebApi.sln | 55 + .../AiModels/HuggingfaceModels.cs | 9 + .../AiModels/OpenAiModels.cs | 35 + .../ConfigureServices.cs | 115 + .../Infrastructure.AgentFramework.csproj | 26 + .../Options/AzureOpenAIOptions.cs | 20 + .../Options/OpenAIOptions.cs | 32 + .../Plugins/ActorsPlugin.cs | 124 + .../Plugins/ChatMessagesPlugin.cs | 63 + .../Plugins/ChatSessionsPlugin.cs | 76 + .../Services/TextGenerationService.cs | 39 + src/Infrastructure.SqlServer/ColumnTypes.cs | 13 + .../ConfigureServices.cs | 22 + src/Infrastructure.SqlServer/GlobalUsings.cs | 2 + .../Infrastructure.SqlServer.csproj | 30 + .../JsonSerializerOptionsProvider.cs | 12 + .../20260130074609_InitialCreate.Designer.cs | 441 ++ .../20260130074609_InitialCreate.cs | 346 ++ .../SemanticKernelContextModelSnapshot.cs | 438 ++ .../Configurations/ActorsConfig.cs | 49 + .../Configurations/ChatMessagesConfig.cs | 24 + .../Configurations/ChatSessionsConfig.cs | 28 + .../Configurations/TextAudioConfig.cs | 29 + .../Configurations/TextImagesConfig.cs | 29 + .../Configurations/TextPromptsConfig.cs | 24 + .../Configurations/TextResponsesConfig.cs | 24 + .../Persistence/SemanticKernelContext.cs | 79 + src/Presentation.Blazor/Clients/.editorconfig | 6 + .../Clients/BackendApiClient.g.cs | 4959 +++++++++++++++++ .../Analytics/ClarityAnalytics.razor | 15 + src/Presentation.Blazor/Components/App.razor | 27 + .../Auth/Components/UserAuthMenu.razor | 31 + .../Auth/Components/UserProfile.razor | 78 + .../Components/Auth/IUserClaimsInfo.cs | 50 + .../DownstreamApiAccessTokenProvider.cs | 26 + .../Middleware/MsGraphAccessTokenProvider.cs | 21 + ...ginLogoutEndpointRouteBuilderExtensions.cs | 34 + .../Auth/Routing/RedirectToAccessDenied.razor | 11 + .../Routing/RedirectToResetPassword.razor | 10 + .../Auth/Routing/RedirectToSignIn.razor | 10 + .../Auth/Routing/RedirectToSignOut.razor | 12 + .../Components/Auth/Routing/RouteConstants.cs | 12 + .../Auth/Services/IUserSyncService.cs | 9 + .../Components/Auth/UserClaimsInfo.cs | 64 + .../Components/FormChangeType.cs | 8 + .../Components/Icons/ArrowIcon.razor | 15 + .../Components/Layout/Error.razor | 40 + .../Components/Layout/MainLayout.razor | 92 + .../Components/Layout/NotFound.razor | 26 + .../Components/Routes.razor | 27 + .../Components/Skeleton/SkeletonList.razor | 50 + .../Components/Skeleton/SkeletonTable.razor | 53 + .../Components/Typography/H1Label.razor | 17 + .../Components/Typography/H2Label.razor | 17 + .../Components/Typography/H3Label.razor | 17 + .../Components/Typography/PLabel.razor | 18 + .../Components/Wizard/WizardExample.razor | 20 + .../Components/Wizard/WizardLayout.razor | 101 + .../Components/Wizard/WizardStep.razor | 27 + .../Components/_Imports.razor | 12 + src/Presentation.Blazor/ConfigureServices.cs | 72 + .../ConfigureServicesAuth.cs | 149 + .../Options/BackendApiOptions.cs | 14 + .../Options/ResilientHttpClientOptions.cs | 7 + .../Chat/Components/ChatMessageList.razor | 27 + .../Chat/Components/ChatSessionEditForm.razor | 42 + .../Chat/Components/ChatSessionList.razor | 103 + .../Chat/Components/ChatSessionScroll.razor | 85 + .../Chat/Components/NewChatMessageCard.razor | 93 + .../Chat/Components/NewChatMessageInput.razor | 91 + .../Components/NewChatSessionButton.razor | 17 + .../Pages/Chat/Models/ChatMessageModel.cs | 25 + .../Pages/Chat/Models/ChatSessionModel.cs | 72 + .../Chat/Models/ChatSessionTreeViewItem.cs | 47 + .../Pages/Chat/Models/ChatSessionsModel.cs | 137 + .../Pages/Chat/Services/ChatService.cs | 70 + src/Presentation.Blazor/Pages/ChatPage.razor | 120 + src/Presentation.Blazor/Pages/HomePage.razor | 60 + src/Presentation.Blazor/Pages/_Imports.razor | 17 + .../Presentation.Blazor.csproj | 29 + src/Presentation.Blazor/Program.cs | 66 + .../Properties/launchSettings.json | 25 + .../Properties/serviceDependencies.json | 8 + .../Properties/serviceDependencies.local.json | 8 + .../Services/ApiService.cs | 55 + .../Services/LocalStorageService.cs | 32 + .../Services/UserSyncService.cs | 73 + .../appsettings.Development.json | 25 + .../appsettings.Local.json | 25 + .../appsettings.Production.json | 25 + src/Presentation.Blazor/appsettings.json | 8 + src/Presentation.Blazor/wwwroot/app.css | 14 + src/Presentation.Blazor/wwwroot/css/site.css | 0 src/Presentation.Blazor/wwwroot/favicon.png | Bin 0 -> 1380 bytes .../wwwroot/img/goodtocode-logo.png | Bin 0 -> 4793 bytes .../Actor/MyActorController.cs | 73 + .../Audio/AdminAudioController.cs | 170 + .../Auth/ClaimsUserInfo.cs | 62 + .../Auth/ConfigureServices.cs | 44 + .../Auth/IClaimsUserInfo.cs | 50 + .../Auth/UserInfoBehavior.cs | 62 + .../AdminChatMessageController.cs | 146 + .../AdminChatSessionController.cs | 241 + .../ChatCompletion/MyChatSessionController.cs | 101 + .../Common/ApiControllerBase.cs | 29 + .../Common/ApiExceptionFilterAttribute.cs | 171 + src/Presentation.WebApi/ConfigureServices.cs | 153 + .../Generate-NswagClientCode.json | 100 + .../Generate-NswagClientCode.ps1 | 50 + src/Presentation.WebApi/GlobalUsings.cs | 9 + .../Image/AdminImageController.cs | 170 + .../Presentation.WebApi.csproj | 40 + src/Presentation.WebApi/Program.cs | 72 + .../Properties/launchSettings.json | 23 + .../Properties/serviceDependencies.json | 12 + .../Properties/serviceDependencies.local.json | 12 + .../AdminTextGenerationController.cs | 186 + .../appsettings.Development.json | 31 + .../appsettings.Production.json | 31 + src/Presentation.WebApi/appsettings.json | 8 + .../appsettings.local.json | 31 + src/Presentation.WebApi/dotnet-tools.json | 5 + .../Actor/CreateActorCommand.feature | 21 + .../Actor/CreateActorCommand.feature.cs | 192 + .../CreateActorCommandStepDefinitions.cs | 105 + .../Actor/DeleteActorCommand.feature | 18 + .../Actor/DeleteActorCommand.feature.cs | 177 + .../DeleteActorCommandStepDefinitions.cs | 76 + .../Actor/GetActorByExternalIdQuery.feature | 19 + .../GetActorByExternalIdQuery.feature.cs | 179 + ...etActorByExternalIdQueryStepDefinitions.cs | 81 + .../Actor/GetActorChatSessionQuery.feature | 20 + .../Actor/GetActorChatSessionQuery.feature.cs | 187 + ...GetActorChatSessionQueryStepDefinitions.cs | 96 + ...GetActorChatSessionsPaginatedQuery.feature | 35 + ...ActorChatSessionsPaginatedQuery.feature.cs | 229 + ...atSessionsPaginatedQueryStepDefinitions.cs | 179 + .../Actor/GetActorChatSessionsQuery.feature | 27 + .../GetActorChatSessionsQuery.feature.cs | 208 + ...etActorChatSessionsQueryStepDefinitions.cs | 132 + .../Actor/GetActorQuery.feature | 20 + .../Actor/GetActorQuery.feature.cs | 182 + .../Actor/GetActorQueryStepDefinitions.cs | 83 + .../Actor/SaveActorCommand.feature | 21 + .../Actor/SaveActorCommand.feature.cs | 192 + .../Actor/SaveActorCommandStepDefinitions.cs | 104 + .../Actor/UpdateActorCommand.feature | 18 + .../Actor/UpdateActorCommand.feature.cs | 177 + .../UpdateActorCommandStepDefinitions.cs | 77 + .../Audio/CreateTextToAudioCommand.feature | 20 + .../Audio/CreateTextToAudioCommand.feature.cs | 186 + ...CreateTextToAudioCommandStepDefinitions.cs | 93 + .../Audio/DeleteTextAudioCommand.feature | 19 + .../Audio/DeleteTextAudioCommand.feature.cs | 180 + .../DeleteTextAudioCommandStepDefinitions.cs | 80 + .../Audio/GetTextAudioQuery.feature | 20 + .../Audio/GetTextAudioQuery.feature.cs | 183 + .../Audio/GetTextAudioQueryStepDefinitions.cs | 88 + .../Audio/GetTextAudiosPaginatedQuery.feature | 33 + .../GetTextAudiosPaginatedQuery.feature.cs | 221 + ...TextAudiosPaginatedQueryStepDefinitions.cs | 171 + .../Audio/GetTextAudiosQuery.feature | 26 + .../Audio/GetTextAudiosQuery.feature.cs | 202 + .../GetTextAudiosQueryStepDefinitions.cs | 130 + .../CreateChatMessageCommand.feature | 23 + .../CreateChatMessageCommand.feature.cs | 196 + ...CreateChatMessageCommandStepDefinitions.cs | 95 + .../CreateChatSessionCommand.feature | 20 + .../CreateChatSessionCommand.feature.cs | 186 + ...CreateChatSessionCommandStepDefinitions.cs | 93 + .../DeleteChatSessionCommand.feature | 19 + .../DeleteChatSessionCommand.feature.cs | 181 + ...DeleteChatSessionCommandStepDefinitions.cs | 76 + .../GetChatMessageQuery.feature | 20 + .../GetChatMessageQuery.feature.cs | 183 + .../GetChatMessageQueryStepDefinitions.cs | 91 + .../GetChatMessagesPaginatedQuery.feature | 33 + .../GetChatMessagesPaginatedQuery.feature.cs | 221 + ...atMessagesPaginatedQueryStepDefinitions.cs | 160 + .../GetChatMessagesQuery.feature | 26 + .../GetChatMessagesQuery.feature.cs | 202 + .../GetChatMessagesQueryStepDefinitions.cs | 120 + .../GetChatSessionQuery.feature | 22 + .../GetChatSessionQuery.feature.cs | 191 + .../GetChatSessionQueryStepDefinitions.cs | 97 + .../GetChatSessionsPaginatedQuery.feature | 33 + .../GetChatSessionsPaginatedQuery.feature.cs | 221 + ...atSessionsPaginatedQueryStepDefinitions.cs | 159 + .../GetChatSessionsQuery.feature | 26 + .../GetChatSessionsQuery.feature.cs | 202 + .../GetChatSessionsQueryStepDefinitions.cs | 119 + .../PatchChatSessionCommand.feature | 19 + .../PatchChatSessionCommand.feature.cs | 185 + .../PatchChatSessionCommandStepDefinitions.cs | 84 + .../UpdateChatSessionCommand.feature | 19 + .../UpdateChatSessionCommand.feature.cs | 181 + ...UpdateChatSessionCommandStepDefinitions.cs | 77 + src/Tests.Specs.Integration/GlobalUsings.cs | 5 + .../Image/CreateTextToImageCommand.feature | 20 + .../Image/CreateTextToImageCommand.feature.cs | 186 + ...CreateTextToImageCommandStepDefinitions.cs | 89 + .../Image/DeleteTextImageCommand.feature | 19 + .../Image/DeleteTextImageCommand.feature.cs | 180 + .../DeleteTextImageCommandStepDefinitions.cs | 79 + .../Image/GetTextImageQuery.feature | 20 + .../Image/GetTextImageQuery.feature.cs | 183 + .../Image/GetTextImageQueryStepDefinitions.cs | 89 + .../Image/GetTextImagesPaginatedQuery.feature | 33 + .../GetTextImagesPaginatedQuery.feature.cs | 221 + ...TextImagesPaginatedQueryStepDefinitions.cs | 163 + .../Image/GetTextImagesQuery.feature | 26 + .../Image/GetTextImagesQuery.feature.cs | 202 + .../GetTextImagesQueryStepDefinitions.cs | 123 + src/Tests.Specs.Integration/TestBase.cs | 157 + src/Tests.Specs.Integration/TestUserInfo.cs | 18 + .../Tests.Specs.Integration.csproj | 41 + .../CreateTextPromptCommand.feature | 20 + .../CreateTextPromptCommand.feature.cs | 186 + .../CreateTextPromptCommandStepDefinitions.cs | 86 + .../DeleteTextPromptCommand.feature | 19 + .../DeleteTextPromptCommand.feature.cs | 181 + .../DeleteTextPromptCommandStepDefinitions.cs | 77 + .../TextGeneration/GetTextPromptQuery.feature | 20 + .../GetTextPromptQuery.feature.cs | 183 + .../GetTextPromptQueryStepDefinitions.cs | 84 + .../GetTextPromptsPaginatedQuery.feature | 33 + .../GetTextPromptsPaginatedQuery.feature.cs | 221 + ...extPromptsPaginatedQueryStepDefinitions.cs | 164 + .../GetTextPromptsQuery.feature | 26 + .../GetTextPromptsQuery.feature.cs | 202 + .../GetTextPromptsQueryStepDefinitions.cs | 124 + .../appsettings.test.json | 18 + src/build.cmd | 5 + src/build.sh | 13 + src/nuget.config | 15 + 438 files changed, 34732 insertions(+), 37 deletions(-) create mode 100644 .azure/modules/afd-azurefrontdoor.bicep create mode 100644 .azure/modules/api-appservice.bicep create mode 100644 .azure/modules/apim-apimanagement.bicep create mode 100644 .azure/modules/app-appservicevnet.bicep create mode 100644 .azure/modules/appcs-appconfigurationsetting.bicep create mode 100644 .azure/modules/appcs-appconfigurationstore.bicep create mode 100644 .azure/modules/appi-applicationinsights.bicep create mode 100644 .azure/modules/bot-botservice.bicep create mode 100644 .azure/modules/cdn-contentdeliverynetwork.bicep create mode 100644 .azure/modules/cog-cognitiveservices.bicep create mode 100644 .azure/modules/cogtext-textanalytics.bicep create mode 100644 .azure/modules/cosmos-cosmosdb.bicep create mode 100644 .azure/modules/dgw-onpremdatagateway.bicep create mode 100644 .azure/modules/ftpcn-ftpapiconnection.bicep create mode 100644 .azure/modules/func-functionsapp.bicep create mode 100644 .azure/modules/hcn-hybridconnection.bicep create mode 100644 .azure/modules/hnbind-hostNameBinding.bicep create mode 100644 .azure/modules/kv-keyvault.bicep create mode 100644 .azure/modules/luis-languageunderstanding.bicep create mode 100644 .azure/modules/nsg-networksecuritygroup.bicep create mode 100644 .azure/modules/nsgrule-networksecuritygrouprule.bicep create mode 100644 .azure/modules/o365cn-o365apiconnection.bicep create mode 100644 .azure/modules/plan-appserviceplan.bicep create mode 100644 .azure/modules/qna-qnamaker.bicep create mode 100644 .azure/modules/redis-rediscache.bicep create mode 100644 .azure/modules/relay-relaynamespace.bicep create mode 100644 .azure/modules/rg-resourcegroup.bicep create mode 100644 .azure/modules/sb-servicebus.bicep create mode 100644 .azure/modules/sent-loganalyticsworkspace.bicep create mode 100644 .azure/modules/sftpcn-sftpsshapiconnection.bicep create mode 100644 .azure/modules/sjc-schedulejobcollection.bicep create mode 100644 .azure/modules/snet-virtualnetworksubnet.bicep create mode 100644 .azure/modules/spocn-sharepointonlineapiconnection.bicep create mode 100644 .azure/modules/sql-sqlserver.bicep create mode 100644 .azure/modules/sql-sqlserverdatabase.bicep create mode 100644 .azure/modules/sqlcn-sqldgwconnection.bicep create mode 100644 .azure/modules/sqldb-sqldatabase.bicep create mode 100644 .azure/modules/sqldb-sqlserverdatabase.bicep create mode 100644 .azure/modules/st-storageaccount.bicep create mode 100644 .azure/modules/stapp-staticwebapp.bicep create mode 100644 .azure/modules/stblobcn-storageblobapiconnection.bicep create mode 100644 .azure/modules/sttablecn-storagetablesapiconnection.bicep create mode 100644 .azure/modules/teamscn-teamsapiconnection.bicep create mode 100644 .azure/modules/vnet-virtualnetwork.bicep create mode 100644 .azure/modules/vnetpeer-virtualnetworkpeering.bicep create mode 100644 .azure/modules/wcert-webcertificate.bicep create mode 100644 .azure/modules/web-appservice.bicep create mode 100644 .azure/modules/work-loganalyticsworkspace.bicep create mode 100644 .azure/scripts/System.psm1 create mode 100644 .azure/scripts/entra/New-EntraAppRegistrationSecret.ps1 create mode 100644 .azure/scripts/entra/New-EntraAppRegistrations.ps1 create mode 100644 .azure/scripts/entra/Set-ApiAppUserSecrets.ps1 create mode 100644 .azure/scripts/entra/Set-WebAppUserSecrets.ps1 create mode 100644 .azure/scripts/hub-and-spoke/New-PlatformHub.ps1 create mode 100644 .azure/scripts/hub-and-spoke/New-PlatformSpoke.ps1 create mode 100644 .azure/scripts/hub-and-spoke/New-VnetPeering.ps1 create mode 100644 .azure/templates/landingzone-standalone-web-api-sql.bicep create mode 100644 .azure/variables/landingzone-standalone-web-api-sql-dev.bicepparam create mode 100644 .github/copilot-instructions.md create mode 100644 .github/scripts/System.psm1 create mode 100644 .github/scripts/cd/New-Github-Azure-Federation.ps1 create mode 100644 .github/scripts/ci/Get-Version.ps1 create mode 100644 .github/scripts/ci/Set-Version.ps1 create mode 100644 .github/scripts/repo/New-GithubRepoBootstrap.ps1 create mode 100644 .github/scripts/repo/New-GithubSecret.ps1 create mode 100644 .github/workflows/gtc-semker-standalone-iac.yml create mode 100644 .github/workflows/gtc-semker-standalone-web-api-sql.yml create mode 100644 data/.vscode/tasks.json create mode 100644 data/Admin/Drop Tables.sql create mode 100644 data/Chat/ChatMessages.sql create mode 100644 data/Chat/ChatSessions.sql create mode 100644 data/Chat/Multimedia.sql create mode 100644 data/Data.sqlproj create mode 100644 data/Identity.sql create mode 100644 docs/AgentFramework-Quick-start-Blazor-Side-by-Side.png create mode 100644 docs/AgentFramework-Quick-start-Blazor-Solution.png create mode 100644 docs/AgentFramework-Quick-start-Blazor-Startup-Projects.png create mode 100644 src/.editorconfig create mode 100644 src/App.razor create mode 100644 src/Core.Application/Abstractions/IActorResponse.cs create mode 100644 src/Core.Application/Abstractions/IActorsPlugin.cs create mode 100644 src/Core.Application/Abstractions/IChatMessagesPlugin.cs create mode 100644 src/Core.Application/Abstractions/IChatSessionsPlugin.cs create mode 100644 src/Core.Application/Abstractions/ISemanticKernelContext.cs create mode 100644 src/Core.Application/Abstractions/ISemanticPluginCompatible.cs create mode 100644 src/Core.Application/Abstractions/IUserInfoRequest.cs create mode 100644 src/Core.Application/Actor/ActorDto.cs create mode 100644 src/Core.Application/Actor/CreateActorCommand.cs create mode 100644 src/Core.Application/Actor/CreateActorCommandValidator.cs create mode 100644 src/Core.Application/Actor/DeleteActorByExternalIdCommand.cs create mode 100644 src/Core.Application/Actor/DeleteActorByExternalIdCommandValidator.cs create mode 100644 src/Core.Application/Actor/DeleteActorCommand.cs create mode 100644 src/Core.Application/Actor/DeleteActorCommandValidator.cs create mode 100644 src/Core.Application/Actor/GetActorChatSessionQuery.cs create mode 100644 src/Core.Application/Actor/GetActorChatSessionQueryValidator.cs create mode 100644 src/Core.Application/Actor/GetActorChatSessionsPaginatedQuery.cs create mode 100644 src/Core.Application/Actor/GetActorChatSessionsPaginatedQueryValidator.cs create mode 100644 src/Core.Application/Actor/GetActorChatSessionsQuery.cs create mode 100644 src/Core.Application/Actor/GetActorChatSessionsQueryValidator.cs create mode 100644 src/Core.Application/Actor/GetActorQuery.cs create mode 100644 src/Core.Application/Actor/GetActorQueryValidator.cs create mode 100644 src/Core.Application/Actor/GetMyActorQuery.cs create mode 100644 src/Core.Application/Actor/GetMyActorQueryValidator.cs create mode 100644 src/Core.Application/Actor/SaveMyActorCommand.cs create mode 100644 src/Core.Application/Actor/SaveMyActorCommandValidator.cs create mode 100644 src/Core.Application/Actor/UpdateActorCommand.cs create mode 100644 src/Core.Application/Actor/UpdateActorCommandValidator.cs create mode 100644 src/Core.Application/Audio/CreateTextToAudioCommand.cs create mode 100644 src/Core.Application/Audio/CreateTextToAudioCommandValidator.cs create mode 100644 src/Core.Application/Audio/DeleteTextAudioCommand.cs create mode 100644 src/Core.Application/Audio/DeleteTextAudioCommandValidator.cs create mode 100644 src/Core.Application/Audio/GetTextAudioQuery.cs create mode 100644 src/Core.Application/Audio/GetTextAudioQueryValidator.cs create mode 100644 src/Core.Application/Audio/GetTextAudiosPaginatedQuery.cs create mode 100644 src/Core.Application/Audio/GetTextAudiosPaginatedQueryValidator.cs create mode 100644 src/Core.Application/Audio/GetTextAudiosQuery.cs create mode 100644 src/Core.Application/Audio/GetTextAudiosQueryValidator.cs create mode 100644 src/Core.Application/Audio/TextAudioDto.cs create mode 100644 src/Core.Application/ChatCompletion/ChatMessageDto.cs create mode 100644 src/Core.Application/ChatCompletion/ChatSessionDto.cs create mode 100644 src/Core.Application/ChatCompletion/CreateChatMessageCommand.cs create mode 100644 src/Core.Application/ChatCompletion/CreateChatMessageCommandValidator.cs create mode 100644 src/Core.Application/ChatCompletion/CreateChatSessionCommand.cs create mode 100644 src/Core.Application/ChatCompletion/CreateChatSessionCommandValidator.cs create mode 100644 src/Core.Application/ChatCompletion/DeleteChatSessionCommand.cs create mode 100644 src/Core.Application/ChatCompletion/DeleteChatSessionCommandValidator.cs create mode 100644 src/Core.Application/ChatCompletion/DeleteTextImageCommand.cs create mode 100644 src/Core.Application/ChatCompletion/DeleteTextImageCommandValidator.cs create mode 100644 src/Core.Application/ChatCompletion/GetChatMessageQuery.cs create mode 100644 src/Core.Application/ChatCompletion/GetChatMessageQueryValidator.cs create mode 100644 src/Core.Application/ChatCompletion/GetChatMessagesPaginatedQuery.cs create mode 100644 src/Core.Application/ChatCompletion/GetChatMessagesPaginatedQueryValidator.cs create mode 100644 src/Core.Application/ChatCompletion/GetChatMessagesQuery.cs create mode 100644 src/Core.Application/ChatCompletion/GetChatMessagesQueryValidator.cs create mode 100644 src/Core.Application/ChatCompletion/GetChatSessionQuery.cs create mode 100644 src/Core.Application/ChatCompletion/GetChatSessionQueryValidator.cs create mode 100644 src/Core.Application/ChatCompletion/GetChatSessionsPaginatedQuery.cs create mode 100644 src/Core.Application/ChatCompletion/GetChatSessionsPaginatedQueryValidator.cs create mode 100644 src/Core.Application/ChatCompletion/GetChatSessionsQuery.cs create mode 100644 src/Core.Application/ChatCompletion/GetChatSessionsQueryValidator.cs create mode 100644 src/Core.Application/ChatCompletion/GetMyChatSessionQuery.cs create mode 100644 src/Core.Application/ChatCompletion/GetMyChatSessionQueryValidator.cs create mode 100644 src/Core.Application/ChatCompletion/GetMyChatSessionsPaginatedQuery.cs create mode 100644 src/Core.Application/ChatCompletion/GetMyChatSessionsPaginatedQueryValidator.cs create mode 100644 src/Core.Application/ChatCompletion/GetMyChatSessionsQuery.cs create mode 100644 src/Core.Application/ChatCompletion/GetMyChatSessionsQueryValidator.cs create mode 100644 src/Core.Application/ChatCompletion/PatchChatSessionCommand.cs create mode 100644 src/Core.Application/ChatCompletion/PatchChatSessionCommandValidator.cs create mode 100644 src/Core.Application/ChatCompletion/UpdateChatSessionCommand.cs create mode 100644 src/Core.Application/ChatCompletion/UpdateChatSessionCommandValidator.cs create mode 100644 src/Core.Application/Common/Behaviors/CustomLoggingBehavior.cs create mode 100644 src/Core.Application/Common/Behaviors/CustomPerformanceBehavior.cs create mode 100644 src/Core.Application/Common/Behaviors/CustomUnhandledExceptionBehavior.cs create mode 100644 src/Core.Application/Common/Behaviors/CustomValidationBehavior.cs create mode 100644 src/Core.Application/Common/CustomLoggerExtensions.cs create mode 100644 src/Core.Application/Common/Exceptions/CustomConflictException.cs create mode 100644 src/Core.Application/Common/Exceptions/CustomForbiddenAccessException.cs create mode 100644 src/Core.Application/Common/Exceptions/CustomNotFoundException.cs create mode 100644 src/Core.Application/Common/Mappings/MappingExtensions.cs create mode 100644 src/Core.Application/Common/Models/PaginatedList.cs create mode 100644 src/Core.Application/ConfigureServices.cs create mode 100644 src/Core.Application/Core.Application.csproj create mode 100644 src/Core.Application/GlobalUsings.cs create mode 100644 src/Core.Application/Image/CreateTextToImageCommand.cs create mode 100644 src/Core.Application/Image/CreateTextToImageCommandValidator.cs create mode 100644 src/Core.Application/Image/GetTextImageQuery.cs create mode 100644 src/Core.Application/Image/GetTextImageQueryValidator.cs create mode 100644 src/Core.Application/Image/GetTextImagesPaginatedQuery.cs create mode 100644 src/Core.Application/Image/GetTextImagesPaginatedQueryValidator.cs create mode 100644 src/Core.Application/Image/GetTextImagesQuery.cs create mode 100644 src/Core.Application/Image/GetTextImagesQueryValidator.cs create mode 100644 src/Core.Application/Image/TextImageDto.cs create mode 100644 src/Core.Application/TextGeneration/CreateTextPromptCommand.cs create mode 100644 src/Core.Application/TextGeneration/CreateTextPromptCommandValidator.cs create mode 100644 src/Core.Application/TextGeneration/DeleteTextPromptCommand.cs create mode 100644 src/Core.Application/TextGeneration/DeleteTextPromptCommandValidator.cs create mode 100644 src/Core.Application/TextGeneration/GetTextPromptQuery.cs create mode 100644 src/Core.Application/TextGeneration/GetTextPromptQueryValidator.cs create mode 100644 src/Core.Application/TextGeneration/GetTextPromptsPaginatedQuery.cs create mode 100644 src/Core.Application/TextGeneration/GetTextPromptsPaginatedQueryValidator.cs create mode 100644 src/Core.Application/TextGeneration/GetTextPromptsQuery.cs create mode 100644 src/Core.Application/TextGeneration/GetTextPromptsQueryValidator.cs create mode 100644 src/Core.Application/TextGeneration/TextPromptDto.cs create mode 100644 src/Core.Application/TextGeneration/TextResponseDto.cs create mode 100644 src/Core.Domain/Actor/ActorEntity.cs create mode 100644 src/Core.Domain/Audio/TextAudioEntity.cs create mode 100644 src/Core.Domain/Auth/IUserEntity.cs create mode 100644 src/Core.Domain/Auth/UserEntity.cs create mode 100644 src/Core.Domain/ChatCompletion/ChatMessageEntity.cs create mode 100644 src/Core.Domain/ChatCompletion/ChatMessageRoles.cs create mode 100644 src/Core.Domain/ChatCompletion/ChatSessionEntity.cs create mode 100644 src/Core.Domain/Core.Domain.csproj create mode 100644 src/Core.Domain/GlobalUsings.cs create mode 100644 src/Core.Domain/Image/TextImageEntity.cs create mode 100644 src/Core.Domain/TextGeneration/TextPromptEntity.cs create mode 100644 src/Core.Domain/TextGeneration/TextResponseEntity.cs create mode 100644 src/Directory.Build.props create mode 100644 src/Directory.Build.targets create mode 100644 src/Get-CodeCoverage.ps1 create mode 100644 src/Goodtocode.AgentFramework.Blazor.sln create mode 100644 src/Goodtocode.AgentFramework.WebApi.sln create mode 100644 src/Infrastructure.AgentFramework/AiModels/HuggingfaceModels.cs create mode 100644 src/Infrastructure.AgentFramework/AiModels/OpenAiModels.cs create mode 100644 src/Infrastructure.AgentFramework/ConfigureServices.cs create mode 100644 src/Infrastructure.AgentFramework/Infrastructure.AgentFramework.csproj create mode 100644 src/Infrastructure.AgentFramework/Options/AzureOpenAIOptions.cs create mode 100644 src/Infrastructure.AgentFramework/Options/OpenAIOptions.cs create mode 100644 src/Infrastructure.AgentFramework/Plugins/ActorsPlugin.cs create mode 100644 src/Infrastructure.AgentFramework/Plugins/ChatMessagesPlugin.cs create mode 100644 src/Infrastructure.AgentFramework/Plugins/ChatSessionsPlugin.cs create mode 100644 src/Infrastructure.AgentFramework/Services/TextGenerationService.cs create mode 100644 src/Infrastructure.SqlServer/ColumnTypes.cs create mode 100644 src/Infrastructure.SqlServer/ConfigureServices.cs create mode 100644 src/Infrastructure.SqlServer/GlobalUsings.cs create mode 100644 src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj create mode 100644 src/Infrastructure.SqlServer/JsonSerializerOptionsProvider.cs create mode 100644 src/Infrastructure.SqlServer/Migrations/20260130074609_InitialCreate.Designer.cs create mode 100644 src/Infrastructure.SqlServer/Migrations/20260130074609_InitialCreate.cs create mode 100644 src/Infrastructure.SqlServer/Migrations/SemanticKernelContextModelSnapshot.cs create mode 100644 src/Infrastructure.SqlServer/Persistence/Configurations/ActorsConfig.cs create mode 100644 src/Infrastructure.SqlServer/Persistence/Configurations/ChatMessagesConfig.cs create mode 100644 src/Infrastructure.SqlServer/Persistence/Configurations/ChatSessionsConfig.cs create mode 100644 src/Infrastructure.SqlServer/Persistence/Configurations/TextAudioConfig.cs create mode 100644 src/Infrastructure.SqlServer/Persistence/Configurations/TextImagesConfig.cs create mode 100644 src/Infrastructure.SqlServer/Persistence/Configurations/TextPromptsConfig.cs create mode 100644 src/Infrastructure.SqlServer/Persistence/Configurations/TextResponsesConfig.cs create mode 100644 src/Infrastructure.SqlServer/Persistence/SemanticKernelContext.cs create mode 100644 src/Presentation.Blazor/Clients/.editorconfig create mode 100644 src/Presentation.Blazor/Clients/BackendApiClient.g.cs create mode 100644 src/Presentation.Blazor/Components/Analytics/ClarityAnalytics.razor create mode 100644 src/Presentation.Blazor/Components/App.razor create mode 100644 src/Presentation.Blazor/Components/Auth/Components/UserAuthMenu.razor create mode 100644 src/Presentation.Blazor/Components/Auth/Components/UserProfile.razor create mode 100644 src/Presentation.Blazor/Components/Auth/IUserClaimsInfo.cs create mode 100644 src/Presentation.Blazor/Components/Auth/Middleware/DownstreamApiAccessTokenProvider.cs create mode 100644 src/Presentation.Blazor/Components/Auth/Middleware/MsGraphAccessTokenProvider.cs create mode 100644 src/Presentation.Blazor/Components/Auth/Routing/LoginLogoutEndpointRouteBuilderExtensions.cs create mode 100644 src/Presentation.Blazor/Components/Auth/Routing/RedirectToAccessDenied.razor create mode 100644 src/Presentation.Blazor/Components/Auth/Routing/RedirectToResetPassword.razor create mode 100644 src/Presentation.Blazor/Components/Auth/Routing/RedirectToSignIn.razor create mode 100644 src/Presentation.Blazor/Components/Auth/Routing/RedirectToSignOut.razor create mode 100644 src/Presentation.Blazor/Components/Auth/Routing/RouteConstants.cs create mode 100644 src/Presentation.Blazor/Components/Auth/Services/IUserSyncService.cs create mode 100644 src/Presentation.Blazor/Components/Auth/UserClaimsInfo.cs create mode 100644 src/Presentation.Blazor/Components/FormChangeType.cs create mode 100644 src/Presentation.Blazor/Components/Icons/ArrowIcon.razor create mode 100644 src/Presentation.Blazor/Components/Layout/Error.razor create mode 100644 src/Presentation.Blazor/Components/Layout/MainLayout.razor create mode 100644 src/Presentation.Blazor/Components/Layout/NotFound.razor create mode 100644 src/Presentation.Blazor/Components/Routes.razor create mode 100644 src/Presentation.Blazor/Components/Skeleton/SkeletonList.razor create mode 100644 src/Presentation.Blazor/Components/Skeleton/SkeletonTable.razor create mode 100644 src/Presentation.Blazor/Components/Typography/H1Label.razor create mode 100644 src/Presentation.Blazor/Components/Typography/H2Label.razor create mode 100644 src/Presentation.Blazor/Components/Typography/H3Label.razor create mode 100644 src/Presentation.Blazor/Components/Typography/PLabel.razor create mode 100644 src/Presentation.Blazor/Components/Wizard/WizardExample.razor create mode 100644 src/Presentation.Blazor/Components/Wizard/WizardLayout.razor create mode 100644 src/Presentation.Blazor/Components/Wizard/WizardStep.razor create mode 100644 src/Presentation.Blazor/Components/_Imports.razor create mode 100644 src/Presentation.Blazor/ConfigureServices.cs create mode 100644 src/Presentation.Blazor/ConfigureServicesAuth.cs create mode 100644 src/Presentation.Blazor/Options/BackendApiOptions.cs create mode 100644 src/Presentation.Blazor/Options/ResilientHttpClientOptions.cs create mode 100644 src/Presentation.Blazor/Pages/Chat/Components/ChatMessageList.razor create mode 100644 src/Presentation.Blazor/Pages/Chat/Components/ChatSessionEditForm.razor create mode 100644 src/Presentation.Blazor/Pages/Chat/Components/ChatSessionList.razor create mode 100644 src/Presentation.Blazor/Pages/Chat/Components/ChatSessionScroll.razor create mode 100644 src/Presentation.Blazor/Pages/Chat/Components/NewChatMessageCard.razor create mode 100644 src/Presentation.Blazor/Pages/Chat/Components/NewChatMessageInput.razor create mode 100644 src/Presentation.Blazor/Pages/Chat/Components/NewChatSessionButton.razor create mode 100644 src/Presentation.Blazor/Pages/Chat/Models/ChatMessageModel.cs create mode 100644 src/Presentation.Blazor/Pages/Chat/Models/ChatSessionModel.cs create mode 100644 src/Presentation.Blazor/Pages/Chat/Models/ChatSessionTreeViewItem.cs create mode 100644 src/Presentation.Blazor/Pages/Chat/Models/ChatSessionsModel.cs create mode 100644 src/Presentation.Blazor/Pages/Chat/Services/ChatService.cs create mode 100644 src/Presentation.Blazor/Pages/ChatPage.razor create mode 100644 src/Presentation.Blazor/Pages/HomePage.razor create mode 100644 src/Presentation.Blazor/Pages/_Imports.razor create mode 100644 src/Presentation.Blazor/Presentation.Blazor.csproj create mode 100644 src/Presentation.Blazor/Program.cs create mode 100644 src/Presentation.Blazor/Properties/launchSettings.json create mode 100644 src/Presentation.Blazor/Properties/serviceDependencies.json create mode 100644 src/Presentation.Blazor/Properties/serviceDependencies.local.json create mode 100644 src/Presentation.Blazor/Services/ApiService.cs create mode 100644 src/Presentation.Blazor/Services/LocalStorageService.cs create mode 100644 src/Presentation.Blazor/Services/UserSyncService.cs create mode 100644 src/Presentation.Blazor/appsettings.Development.json create mode 100644 src/Presentation.Blazor/appsettings.Local.json create mode 100644 src/Presentation.Blazor/appsettings.Production.json create mode 100644 src/Presentation.Blazor/appsettings.json create mode 100644 src/Presentation.Blazor/wwwroot/app.css create mode 100644 src/Presentation.Blazor/wwwroot/css/site.css create mode 100644 src/Presentation.Blazor/wwwroot/favicon.png create mode 100644 src/Presentation.Blazor/wwwroot/img/goodtocode-logo.png create mode 100644 src/Presentation.WebApi/Actor/MyActorController.cs create mode 100644 src/Presentation.WebApi/Audio/AdminAudioController.cs create mode 100644 src/Presentation.WebApi/Auth/ClaimsUserInfo.cs create mode 100644 src/Presentation.WebApi/Auth/ConfigureServices.cs create mode 100644 src/Presentation.WebApi/Auth/IClaimsUserInfo.cs create mode 100644 src/Presentation.WebApi/Auth/UserInfoBehavior.cs create mode 100644 src/Presentation.WebApi/ChatCompletion/AdminChatMessageController.cs create mode 100644 src/Presentation.WebApi/ChatCompletion/AdminChatSessionController.cs create mode 100644 src/Presentation.WebApi/ChatCompletion/MyChatSessionController.cs create mode 100644 src/Presentation.WebApi/Common/ApiControllerBase.cs create mode 100644 src/Presentation.WebApi/Common/ApiExceptionFilterAttribute.cs create mode 100644 src/Presentation.WebApi/ConfigureServices.cs create mode 100644 src/Presentation.WebApi/Generate-NswagClientCode.json create mode 100644 src/Presentation.WebApi/Generate-NswagClientCode.ps1 create mode 100644 src/Presentation.WebApi/GlobalUsings.cs create mode 100644 src/Presentation.WebApi/Image/AdminImageController.cs create mode 100644 src/Presentation.WebApi/Presentation.WebApi.csproj create mode 100644 src/Presentation.WebApi/Program.cs create mode 100644 src/Presentation.WebApi/Properties/launchSettings.json create mode 100644 src/Presentation.WebApi/Properties/serviceDependencies.json create mode 100644 src/Presentation.WebApi/Properties/serviceDependencies.local.json create mode 100644 src/Presentation.WebApi/TextGeneration/AdminTextGenerationController.cs create mode 100644 src/Presentation.WebApi/appsettings.Development.json create mode 100644 src/Presentation.WebApi/appsettings.Production.json create mode 100644 src/Presentation.WebApi/appsettings.json create mode 100644 src/Presentation.WebApi/appsettings.local.json create mode 100644 src/Presentation.WebApi/dotnet-tools.json create mode 100644 src/Tests.Specs.Integration/Actor/CreateActorCommand.feature create mode 100644 src/Tests.Specs.Integration/Actor/CreateActorCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/Actor/CreateActorCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Actor/DeleteActorCommand.feature create mode 100644 src/Tests.Specs.Integration/Actor/DeleteActorCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/Actor/DeleteActorCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Actor/GetActorByExternalIdQuery.feature create mode 100644 src/Tests.Specs.Integration/Actor/GetActorByExternalIdQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/Actor/GetActorByExternalIdQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Actor/GetActorChatSessionQuery.feature create mode 100644 src/Tests.Specs.Integration/Actor/GetActorChatSessionQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/Actor/GetActorChatSessionQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Actor/GetActorChatSessionsPaginatedQuery.feature create mode 100644 src/Tests.Specs.Integration/Actor/GetActorChatSessionsPaginatedQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/Actor/GetActorChatSessionsPaginatedQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Actor/GetActorChatSessionsQuery.feature create mode 100644 src/Tests.Specs.Integration/Actor/GetActorChatSessionsQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/Actor/GetActorChatSessionsQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Actor/GetActorQuery.feature create mode 100644 src/Tests.Specs.Integration/Actor/GetActorQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/Actor/GetActorQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Actor/SaveActorCommand.feature create mode 100644 src/Tests.Specs.Integration/Actor/SaveActorCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/Actor/SaveActorCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Actor/UpdateActorCommand.feature create mode 100644 src/Tests.Specs.Integration/Actor/UpdateActorCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/Actor/UpdateActorCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Audio/CreateTextToAudioCommand.feature create mode 100644 src/Tests.Specs.Integration/Audio/CreateTextToAudioCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/Audio/CreateTextToAudioCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Audio/DeleteTextAudioCommand.feature create mode 100644 src/Tests.Specs.Integration/Audio/DeleteTextAudioCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/Audio/DeleteTextAudioCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Audio/GetTextAudioQuery.feature create mode 100644 src/Tests.Specs.Integration/Audio/GetTextAudioQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/Audio/GetTextAudioQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Audio/GetTextAudiosPaginatedQuery.feature create mode 100644 src/Tests.Specs.Integration/Audio/GetTextAudiosPaginatedQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/Audio/GetTextAudiosPaginatedQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Audio/GetTextAudiosQuery.feature create mode 100644 src/Tests.Specs.Integration/Audio/GetTextAudiosQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/Audio/GetTextAudiosQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/CreateChatMessageCommand.feature create mode 100644 src/Tests.Specs.Integration/ChatCompletion/CreateChatMessageCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/CreateChatMessageCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/CreateChatSessionCommand.feature create mode 100644 src/Tests.Specs.Integration/ChatCompletion/CreateChatSessionCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/CreateChatSessionCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/DeleteChatSessionCommand.feature create mode 100644 src/Tests.Specs.Integration/ChatCompletion/DeleteChatSessionCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/DeleteChatSessionCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatMessageQuery.feature create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatMessageQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatMessageQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesPaginatedQuery.feature create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesPaginatedQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesPaginatedQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesQuery.feature create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatSessionQuery.feature create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatSessionQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatSessionQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsPaginatedQuery.feature create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsPaginatedQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsPaginatedQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsQuery.feature create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/PatchChatSessionCommand.feature create mode 100644 src/Tests.Specs.Integration/ChatCompletion/PatchChatSessionCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/PatchChatSessionCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/UpdateChatSessionCommand.feature create mode 100644 src/Tests.Specs.Integration/ChatCompletion/UpdateChatSessionCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/ChatCompletion/UpdateChatSessionCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/GlobalUsings.cs create mode 100644 src/Tests.Specs.Integration/Image/CreateTextToImageCommand.feature create mode 100644 src/Tests.Specs.Integration/Image/CreateTextToImageCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/Image/CreateTextToImageCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Image/DeleteTextImageCommand.feature create mode 100644 src/Tests.Specs.Integration/Image/DeleteTextImageCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/Image/DeleteTextImageCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Image/GetTextImageQuery.feature create mode 100644 src/Tests.Specs.Integration/Image/GetTextImageQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/Image/GetTextImageQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Image/GetTextImagesPaginatedQuery.feature create mode 100644 src/Tests.Specs.Integration/Image/GetTextImagesPaginatedQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/Image/GetTextImagesPaginatedQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/Image/GetTextImagesQuery.feature create mode 100644 src/Tests.Specs.Integration/Image/GetTextImagesQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/Image/GetTextImagesQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/TestBase.cs create mode 100644 src/Tests.Specs.Integration/TestUserInfo.cs create mode 100644 src/Tests.Specs.Integration/Tests.Specs.Integration.csproj create mode 100644 src/Tests.Specs.Integration/TextGeneration/CreateTextPromptCommand.feature create mode 100644 src/Tests.Specs.Integration/TextGeneration/CreateTextPromptCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/TextGeneration/CreateTextPromptCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/TextGeneration/DeleteTextPromptCommand.feature create mode 100644 src/Tests.Specs.Integration/TextGeneration/DeleteTextPromptCommand.feature.cs create mode 100644 src/Tests.Specs.Integration/TextGeneration/DeleteTextPromptCommandStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/TextGeneration/GetTextPromptQuery.feature create mode 100644 src/Tests.Specs.Integration/TextGeneration/GetTextPromptQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/TextGeneration/GetTextPromptQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/TextGeneration/GetTextPromptsPaginatedQuery.feature create mode 100644 src/Tests.Specs.Integration/TextGeneration/GetTextPromptsPaginatedQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/TextGeneration/GetTextPromptsPaginatedQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/TextGeneration/GetTextPromptsQuery.feature create mode 100644 src/Tests.Specs.Integration/TextGeneration/GetTextPromptsQuery.feature.cs create mode 100644 src/Tests.Specs.Integration/TextGeneration/GetTextPromptsQueryStepDefinitions.cs create mode 100644 src/Tests.Specs.Integration/appsettings.test.json create mode 100644 src/build.cmd create mode 100644 src/build.sh create mode 100644 src/nuget.config diff --git a/.azure/modules/afd-azurefrontdoor.bicep b/.azure/modules/afd-azurefrontdoor.bicep new file mode 100644 index 0000000..88c7c03 --- /dev/null +++ b/.azure/modules/afd-azurefrontdoor.bicep @@ -0,0 +1,31 @@ + + +@description('Name of the Azure Front Door instance. Must be globally unique and 3-63 characters, using only lowercase letters, numbers, and hyphens, starting and ending with a letter or number.') +@minLength(3) +@maxLength(63) +param name string + +@description('Location for the Azure Front Door resource. Must be set to global.') +@allowed(['global']) +param location string = 'global' + +@description('Tags to apply to the Azure Front Door resource.') +param tags object = {} + +@description('SKU for Azure Front Door. Allowed values: Standard_AzureFrontDoor, Premium_AzureFrontDoor. Default is Standard_AzureFrontDoor.') +@allowed([ + 'Standard_AzureFrontDoor' + 'Premium_AzureFrontDoor' +]) +param sku string = 'Standard_AzureFrontDoor' + +resource afd 'Microsoft.Cdn/profiles@2023-05-01' = { + name: name + location: location + tags: tags + sku: { + name: sku + } +} + +output id string = afd.id diff --git a/.azure/modules/api-appservice.bicep b/.azure/modules/api-appservice.bicep new file mode 100644 index 0000000..81c296d --- /dev/null +++ b/.azure/modules/api-appservice.bicep @@ -0,0 +1,95 @@ + +@description('The name of the App Service Web App. Must be 1-60 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(60) +param name string + +@description('The Azure region where the Web App will be deployed.') +param location string + +@description('Tags to apply to the Web App resource.') +param tags object = {} + +@description('The environment for the Web App. Allowed values: Development, QA, Staging, Production. Default is Development.') +@allowed([ + 'Development' + 'QA' + 'Staging' + 'Production' +]) +param environment string = 'Development' + +@description('The Application Insights instrumentation key for the Web App.') +@minLength(1) +param appiKey string + +@description('The Application Insights connection string for the Web App.') +@minLength(1) +param appiConnection string + +@description('The resource ID of the App Service Plan.') +@minLength(1) +param planId string + +@description('The kind of the Web App. Allowed values: api, app, app,linux, functionapp, functionapp,linux. Default is app.') +@allowed([ + 'api' + 'app' + 'app,linux' + 'functionapp' + 'functionapp,linux' +]) +param kind string = 'app' + +@description('The .NET version for the Web App. Allowed values: v4.8 (for .NET Framework), 6.0, 7.0, 8.0, 9.0, 10.0 (for .NET). Default is 10.0.') +@allowed([ + 'v4.8' + '6.0' + '7.0' + '8.0' + '9.0' + '10.0' +]) +param dotnetVersion string = '10.0' + +@description('Enable Always On for the App Service') +param alwaysOn bool = false + +resource apiAppResource 'Microsoft.Web/sites@2023-12-01' = { + name: name + location: location + kind: kind + tags: empty(tags) ? null : tags + properties: { + serverFarmId: planId + httpsOnly: true + siteConfig: { + netFrameworkVersion: dotnetVersion + ftpsState: 'Disabled' + alwaysOn: alwaysOn + appSettings: [ + { + name: 'APPINSIGHTS_INSTRUMENTATIONKEY' + value: appiKey + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: appiConnection + } + { + name: 'ASPNETCORE_ENVIRONMENT' + value: environment + } + { + name: 'WEBSITE_RUN_FROM_PACKAGE' + value: '1' + } + ] + } + } + identity: { + type: 'SystemAssigned' + } +} + +output id string = apiAppResource.id diff --git a/.azure/modules/apim-apimanagement.bicep b/.azure/modules/apim-apimanagement.bicep new file mode 100644 index 0000000..2a1a0b7 --- /dev/null +++ b/.azure/modules/apim-apimanagement.bicep @@ -0,0 +1,49 @@ + +@description('The name of the API Management service instance. Must be 1-50 characters, use only letters, numbers, and hyphens, and start/end with a letter or number.') +@minLength(1) +@maxLength(50) +param name string = 'apiservice${uniqueString(resourceGroup().id)}' + +@description('The email address of the owner of the API Management service. Must be a valid email address.') +@minLength(5) +@maxLength(100) +param publisherEmail string + +@description('The name of the owner of the API Management service. Must be at least 1 character.') +@minLength(1) +param publisherName string + +@description('The pricing tier (SKU) of this API Management service. Allowed values: Developer, Standard, Premium. Default is Developer.') +@allowed([ + 'Developer' + 'Standard' + 'Premium' +]) +param sku string = 'Developer' + +@description('Tags to apply to the API Management service resource.') +param tags object = {} + +@description('The instance size (capacity) of this API Management service. Allowed values: 1, 2. Default is 1.') +@allowed([ + 1 + 2 +]) +param capacity int = 1 + +@description('Location for all resources. Defaults to the resource group location.') +param location string = resourceGroup().location + +resource apimResource 'Microsoft.ApiManagement/service@2023-05-01-preview' = { + name: name + location: location + tags: tags + sku: { + name: sku + capacity: capacity + } + properties: { + publisherEmail: publisherEmail + publisherName: publisherName + } +} diff --git a/.azure/modules/app-appservicevnet.bicep b/.azure/modules/app-appservicevnet.bicep new file mode 100644 index 0000000..510610c --- /dev/null +++ b/.azure/modules/app-appservicevnet.bicep @@ -0,0 +1,32 @@ +@description('The location in which all resources should be deployed.') +param location string = resourceGroup().location + +@description('The name of the app to create.') +param appName string = 'appName' + +resource existingserverfarm 'Microsoft.Web/serverfarms@2023-01-01' existing={ + name:'existingserverfarm' +} + +resource vnetname 'Microsoft.Network/virtualNetworks@2023-06-01' existing ={ + name:'vnetname' +} +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2023-06-01' existing ={ + name:'subnet' + parent: vnetname + +} + +resource webApp 'Microsoft.Web/sites@2023-01-01' = { + name: appName + location: location + kind: 'app' + properties: { + serverFarmId: existingserverfarm.id + virtualNetworkSubnetId:subnet.id + httpsOnly: true + siteConfig: { + http20Enabled: true + } + } +} diff --git a/.azure/modules/appcs-appconfigurationsetting.bicep b/.azure/modules/appcs-appconfigurationsetting.bicep new file mode 100644 index 0000000..4cfa993 --- /dev/null +++ b/.azure/modules/appcs-appconfigurationsetting.bicep @@ -0,0 +1,35 @@ +@description('Specifies the name of the App Configuration store.') +param name string + +@description('Sentinel key to refresh configs for no label and Development label.') +param appcsKeys array = [ + 'Shared:Sentinel' + 'Shared:Sentinel$Development' +] + +@description('Sentinel value is 1. When changed to 0, will trigger config refreshes.') +param appcsValues array = [ + '1' + '1' +] + +@description('Specifies the content type of the key-value resources. For feature flag, the value should be application/vnd.microsoft.appconfig.ff+json;charset=utf-8. For Key Value reference, the value should be application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8. Otherwise, it\'s optional.') +param contentType string = 'text/plain' + +@description('Adds tags for the key-value resources. It\'s optional') +param tags object = { + tag1: 'tag-value-1' + tag2: 'tag-value-2' +} + +resource name_appcsKeys 'Microsoft.AppConfiguration/configurationStores/keyValues@2023-03-01' = [for (item, i) in appcsKeys: { + name: '${name}/${item}' + properties: { + value: appcsValues[i] + contentType: contentType + tags: empty(tags) ? null : tags + } +}] + +output reference_key_value_value string = reference(resourceId('Microsoft.AppConfiguration/configurationStores/keyValues', name, appcsKeys[0]), '2020-07-01-preview').value +output reference_key_value_object object = reference(resourceId('Microsoft.AppConfiguration/configurationStores/keyValues', name, appcsKeys[1]), '2020-07-01-preview') diff --git a/.azure/modules/appcs-appconfigurationstore.bicep b/.azure/modules/appcs-appconfigurationstore.bicep new file mode 100644 index 0000000..09372d2 --- /dev/null +++ b/.azure/modules/appcs-appconfigurationstore.bicep @@ -0,0 +1,31 @@ +@description('Specifies the name of the App Configuration store. 5-50 chars, lowercase letters, numbers, and -') +@minLength(5) +@maxLength(50) +param name string + +@description('Specifies the sku of the App Configuration store.') +@allowed([ + 'free' + 'standard' +]) +param sku string = 'free' + +@description('Specifies the Azure location where the app configuration store should be created.') +param location string = toLower(replace(resourceGroup().location, ' ', '')) + +resource name_resource 'Microsoft.AppConfiguration/configurationStores@2023-03-01' = { + name: name + location: location + sku: { + name: sku + } + identity: { + type: 'SystemAssigned' + } + properties: { + disableLocalAuth: true + publicNetworkAccess: 'Disabled' + } +} + + diff --git a/.azure/modules/appi-applicationinsights.bicep b/.azure/modules/appi-applicationinsights.bicep new file mode 100644 index 0000000..972a187 --- /dev/null +++ b/.azure/modules/appi-applicationinsights.bicep @@ -0,0 +1,39 @@ + + +@description('The Azure region where the Application Insights resource will be deployed. Allowed: eastus, eastus2, westus, westus2, centralus.') +@allowed([ + 'eastus' + 'eastus2' + 'westus' + 'westus2' + 'centralus' +]) +param location string + +@description('Tags to apply to the Application Insights resource.') +param tags object = {} + +@description('Specifies the name of the Application Insights resource. 1-255 characters, letters, numbers, and -') +@minLength(1) +@maxLength(255) +param name string + +@description('The resource ID of the Log Analytics workspace to link to Application Insights. Must be a valid resourceId string.') +@minLength(1) +param workResourceId string + +resource appiResource 'Microsoft.Insights/components@2020-02-02' = { + name: name + location: location + tags: empty(tags) ? null : tags + kind:'web' + properties: { + Application_Type: 'web' + Flow_Type: 'Bluefield' + WorkspaceResourceId: workResourceId + } +} + +output id string = appiResource.id +output InstrumentationKey string = appiResource.properties.InstrumentationKey +output Connectionstring string = appiResource.properties.ConnectionString diff --git a/.azure/modules/bot-botservice.bicep b/.azure/modules/bot-botservice.bicep new file mode 100644 index 0000000..907d131 --- /dev/null +++ b/.azure/modules/bot-botservice.bicep @@ -0,0 +1,76 @@ + +@description('The name of the Bot Service resource. Must be 2-64 characters, using only letters, numbers, and hyphens, starting and ending with a letter or number.') +@minLength(2) +@maxLength(64) +param name string + + +@description('The SKU (pricing tier) for the Bot Service. Allowed values: F0 (Free), S1 (Standard). Default is S1.') +@allowed([ + 'F0' + 'S1' +]) +param sku string = 'S1' + +@description('The Microsoft App ID for the Bot Service. Must be a valid GUID.') +@minLength(1) +param msAppId string + +@description('The Microsoft App password/secret value for the Bot Service. Required for authentication.') +@minLength(1) +param msAppValue string + +@description('The display name for the Bot Service. Optional, defaults to the resource name if not provided.') +@maxLength(64) +param displayName string = '' + +@description('Tags to apply to the Bot Service resource.') +param tags object = {} + +var location = resourceGroup().location +var uniqueSuffix = toLower(substring(uniqueString(resourceGroup().id, 'Microsoft.BotService/bots', name), 0, 6)) +var botDisplayName = empty(displayName) ? name : displayName +var kvName = 'kv-${name}' +var appPasswordSecret = 'bot-${replace(name, '_', '-')}-pwd-${uniqueSuffix}' +var appPasswordSecretId = empty(msAppValue) ? '' : keyVaultName_appPasswordSecret.id + +resource keyVaultName 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: kvName + location: location + properties: { + tenantId: subscription().tenantId + sku: { + family: 'A' + name: 'standard' + } + accessPolicies: [] + enabledForTemplateDeployment: true + } +} + + +resource keyVaultName_appPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(msAppValue)) { + parent: keyVaultName + name: appPasswordSecret + properties: { + value: msAppValue + } +} + + +resource name_resource 'Microsoft.BotService/botServices@2022-09-15' = { + name: name + kind: 'azurebot' + location: 'global' + sku: { + name: sku + } + tags: tags + properties: { + displayName: botDisplayName + msaAppId: msAppId + openWithHint: 'bfcomposer://' + appPasswordHint: appPasswordSecretId + endpoint: 'https://REPLACE-WITH-YOUR-BOT-ENDPOINT/api/messages' + } +} diff --git a/.azure/modules/cdn-contentdeliverynetwork.bicep b/.azure/modules/cdn-contentdeliverynetwork.bicep new file mode 100644 index 0000000..9d18a51 --- /dev/null +++ b/.azure/modules/cdn-contentdeliverynetwork.bicep @@ -0,0 +1,28 @@ + +@description('The name of the Storage Account for CDN. Must be 3-24 characters, using only lowercase letters and numbers.') +@minLength(3) +@maxLength(24) +param name string + +@description('The SKU (pricing tier) for the Storage Account. Allowed values: Standard_LRS, Standard_GRS, Standard_RAGRS, Standard_ZRS, Premium_LRS, Premium_ZRS. Default is Standard_LRS.') +@allowed([ + 'Standard_LRS' + 'Standard_GRS' + 'Standard_RAGRS' + 'Standard_ZRS' + 'Premium_LRS' + 'Premium_ZRS' +]) +param sku string = 'Standard_LRS' + +resource storageAccountName 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: name + location: resourceGroup().location + tags: { + displayName: name + } + sku: { + name: sku + } + kind: 'Storage' +} diff --git a/.azure/modules/cog-cognitiveservices.bicep b/.azure/modules/cog-cognitiveservices.bicep new file mode 100644 index 0000000..e64cf55 --- /dev/null +++ b/.azure/modules/cog-cognitiveservices.bicep @@ -0,0 +1,21 @@ + +@description('The name of the Cognitive Services account. Must be unique within Azure. Recommended format: -. 3-64 characters, lowercase letters, numbers, and hyphens.') +@minLength(3) +@maxLength(64) +param name string = 'CognitiveService-${uniqueString(resourceGroup().id)}' + + +@description('The SKU (pricing tier) for the Cognitive Services account. Allowed values: S0 (Standard). Default is S0.') +@allowed([ + 'S0' +]) +param sku string = 'S0' + +resource name_resource 'Microsoft.CognitiveServices/accounts@2023-05-01' = { + name: name + location: resourceGroup().location + sku: { + name: sku + } + kind: 'CognitiveServices' +} diff --git a/.azure/modules/cogtext-textanalytics.bicep b/.azure/modules/cogtext-textanalytics.bicep new file mode 100644 index 0000000..9373790 --- /dev/null +++ b/.azure/modules/cogtext-textanalytics.bicep @@ -0,0 +1,24 @@ +@description('That name is the name of our application. It has to be unique.Type a name followed by your resource group name. (-)') +param name string = 'CognitiveService-${uniqueString(resourceGroup().id)}' + +@description('Sku (pricing tier) of this resource') +@allowed([ + 'F0' + 'S' +]) +param sku string = 'F0' + +@description('Location (region) of this resource') +param location string = toLower(replace(resourceGroup().location, ' ', '')) + +resource name_resource 'Microsoft.CognitiveServices/accounts@2023-05-01' = { + kind: 'TextAnalytics' + name: name + location: location + sku: { + name: sku + } + properties: { + customSubDomainName: name + } +} diff --git a/.azure/modules/cosmos-cosmosdb.bicep b/.azure/modules/cosmos-cosmosdb.bicep new file mode 100644 index 0000000..78b8f61 --- /dev/null +++ b/.azure/modules/cosmos-cosmosdb.bicep @@ -0,0 +1,46 @@ + +@description('The name of the Cosmos DB account. Must be 3-44 characters, using only lowercase letters, numbers, and hyphens.') +@minLength(3) +@maxLength(44) +param name string + +@description('The default consistency level for the Cosmos DB account. Allowed values: Eventual, Strong, Session, BoundedStaleness. Default is Session.') +@allowed([ + 'Eventual' + 'Strong' + 'Session' + 'BoundedStaleness' +]) +param consistencyLevel string = 'Session' + +@description('The max staleness prefix for BoundedStaleness consistency. Required if consistencyLevel is BoundedStaleness. Default is 10.') +@minValue(10) +@maxValue(1000) +param maxStalenessPrefix int = 10 + +@description('The max interval in seconds for BoundedStaleness consistency. Required if consistencyLevel is BoundedStaleness. Default is 5.') +@minValue(5) +@maxValue(600) +param maxIntervalInSeconds int = 5 + +var offerType = 'Standard' + +resource name_resource 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = { + name: name + location: resourceGroup().location + properties: { + databaseAccountOfferType: offerType + consistencyPolicy: { + defaultConsistencyLevel: consistencyLevel + maxStalenessPrefix: maxStalenessPrefix + maxIntervalInSeconds: maxIntervalInSeconds + } + locations: [ + { + locationName: resourceGroup().location + failoverPriority: 0 + isZoneRedundant: false + } + ] + } +} diff --git a/.azure/modules/dgw-onpremdatagateway.bicep b/.azure/modules/dgw-onpremdatagateway.bicep new file mode 100644 index 0000000..bcd9894 --- /dev/null +++ b/.azure/modules/dgw-onpremdatagateway.bicep @@ -0,0 +1,33 @@ + +@description('The name of the On-premises Data Gateway. Must be 1-80 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(80) +param name string + +@description('The Azure region where the On-premises Data Gateway will be deployed.') +param location string = resourceGroup().location + +@description('The installation ID for the On-premises Data Gateway.') +@minLength(1) +@maxLength(128) +param dgwInstallationId string + +@description('The subscription ID for the On-premises Data Gateway. Defaults to the current subscription.') +param subscriptionId string = subscription().id + +@description('Tags to apply to the On-premises Data Gateway resource.') +param tags object = {} + +var locationShortName = toLower(replace(location, ' ', '')) +var gatewayInstallationId = '${subscriptionId}/providers/Microsoft.Web/locations/${locationShortName}/connectionGatewayInstallations/${dgwInstallationId}' + +resource name_resource 'Microsoft.Web/connectionGateways@2016-06-01' = { + name: name + location: locationShortName + tags: tags + properties: { + connectionGatewayInstallation: { + id: gatewayInstallationId + } + } +} diff --git a/.azure/modules/ftpcn-ftpapiconnection.bicep b/.azure/modules/ftpcn-ftpapiconnection.bicep new file mode 100644 index 0000000..e95bbb2 --- /dev/null +++ b/.azure/modules/ftpcn-ftpapiconnection.bicep @@ -0,0 +1,58 @@ + +@description('The name of the FTP API Connection. Must be 1-80 characters, using only alphanumeric characters and hyphens. Default is azureblob.') +@minLength(1) +@maxLength(80) +param name string = 'azureblob' + +@description('The address of the FTP server.') +@minLength(1) +@maxLength(255) +param ftpServerAddress string + +@description('The port of the FTP server. Default is 21.') +@minLength(1) +@maxLength(5) +param ftpServerPort string = '21' + +@description('The username for the FTP server.') +@minLength(1) +@maxLength(128) +param ftpUsername string + +@description('The password for the FTP server.') +@minLength(1) +@maxLength(128) +@secure() +param ftpPassword string + +@description('Tags to apply to the FTP API Connection resource.') +param tags object = {} + +var locationShortName = toLower(replace(resourceGroup().location, ' ', '')) +var nameLower = toLower(replace(replace(name, '-', ''), ' ', '')) + +resource connection 'Microsoft.Web/connections@2016-06-01' = { + name: nameLower + location: locationShortName + tags: tags + properties: { + displayName: name + customParameterValues: {} + api: { + name: '${nameLower}sftpwithssh' + displayName: 'SFTP - SSH' + description: 'SFTP (SSH File Transfer Protocol) is a network protocol that provides file access, file transfer, and file management over any reliable data stream. It was designed by the Internet Engineering Task Force (IETF) as an extension of the Secure Shell protocol (SSH) version 2.0 to provide secure file transfer capabilities.' + iconUri: 'https://connectoricons-prod.azureedge.net/releases/v1.0.1518/1.0.1518.2564/sftpwithssh/icon.png' + brandColor: '#e8bb00' + id: subscriptionResourceId('Microsoft.Web/locations/managedApis', locationShortName, 'ftp') + type: 'Microsoft.Web/locations/managedApis' + } + parameterValues: { + serverAddress: ftpServerAddress + userName: ftpUsername + password: ftpPassword + serverPort: ftpServerPort + } + } + dependsOn: [] +} diff --git a/.azure/modules/func-functionsapp.bicep b/.azure/modules/func-functionsapp.bicep new file mode 100644 index 0000000..18ecc57 --- /dev/null +++ b/.azure/modules/func-functionsapp.bicep @@ -0,0 +1,125 @@ + +@description('The name of the Azure Function App. Must be 1-60 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(60) +param name string + +@description('The Azure region where the Function App will be deployed.') +param location string + +@description('Tags to apply to the Function App resource.') +param tags object = {} + +@description('The resource ID of the App Service Plan.') +@minLength(1) +param planId string + +@description('The name of the Storage Account for the Function App.') +@minLength(3) +@maxLength(24) +param stName string + +@description('The subscription ID for the Storage Account. Defaults to the current subscription.') +param stSubscriptionId string = subscription().subscriptionId + +@description('The resource group name for the Storage Account. Defaults to the current resource group.') +param stResourceGroupName string = resourceGroup().name + +@description('The Application Insights instrumentation key for the Function App.') +@minLength(1) +param appiKey string + +@description('The Application Insights connection string for the Function App.') +@minLength(1) +param appiConnection string + +@description('Whether to use a 32-bit worker process. Default is true.') +param use32BitWorkerProcess bool = true + + +@description('The environment for the Function App. Allowed values: Development, QA, Staging, Production.') +@allowed([ + 'Development' + 'QA' + 'Staging' + 'Production' +]) +param environmentApp string + + +@description('The runtime for the Function App. Allowed values: dotnet, python, dotnet-isolated. Default is dotnet.') +@allowed([ + 'dotnet' + 'python' + 'dotnet-isolated' +]) +param funcRuntime string = 'dotnet' + + +@description('The version of the Azure Functions runtime. Allowed values: 1, 2, 3, 4. Default is 4.') +@allowed([ + 1 + 2 + 3 + 4 +]) +param funcVersion int = 4 + + +@description('Whether the Function App is always on. Default is false.') +param alwaysOn bool = false + +resource functionapp 'Microsoft.Web/sites@2023-12-01' = { + name: name + kind: 'functionapp' + location: location + tags: empty(tags) ? null : tags + properties: { + serverFarmId: planId + siteConfig: { + alwaysOn: alwaysOn + appSettings: [ + { + name: 'FUNCTIONS_EXTENSION_VERSION' + value: '~${funcVersion}' + } + { + name: 'FUNCTIONS_WORKER_RUNTIME' + value: funcRuntime + } + { + name: 'APPINSIGHTS_INSTRUMENTATIONKEY' + value: appiKey + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: appiConnection + } + { + name: 'AzureWebJobsStorage' + value: 'DefaultEndpointsProtocol=https;AccountName=${stName};AccountKey=${listKeys(resourceId(stSubscriptionId, stResourceGroupName, 'Microsoft.Storage/storageAccounts', stName), '2019-06-01').keys[0].value};EndpointSuffix=core.windows.net' + } + { + name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING' + value: 'DefaultEndpointsProtocol=https;AccountName=${stName};AccountKey=${listKeys(resourceId(stSubscriptionId, stResourceGroupName, 'Microsoft.Storage/storageAccounts', stName), '2019-06-01').keys[0].value};EndpointSuffix=core.windows.net' + } + { + name: 'WEBSITE_CONTENTSHARE' + value: toLower(name) + } + { + name: 'ASPNETCORE_ENVIRONMENT' + value: environmentApp + } + { + name: 'AZURE_FUNCTIONS_ENVIRONMENT' + value: environmentApp + } + ] + use32BitWorkerProcess: use32BitWorkerProcess + } + } + identity: { + type: 'SystemAssigned' + } +} diff --git a/.azure/modules/hcn-hybridconnection.bicep b/.azure/modules/hcn-hybridconnection.bicep new file mode 100644 index 0000000..e354e26 --- /dev/null +++ b/.azure/modules/hcn-hybridconnection.bicep @@ -0,0 +1,17 @@ + +@description('The name of the Hybrid Connection. Must be 1-80 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(80) +param name string + +@description('The name of the Azure Relay namespace. Must be 6-50 characters, using only alphanumeric characters and hyphens.') +@minLength(6) +@maxLength(50) +param relayName string + +resource relayName_name 'Microsoft.Relay/namespaces/hybridConnections@2021-11-01' = { + name: '${relayName}/${name}' + properties: { + requiresClientAuthorization: true + } +} diff --git a/.azure/modules/hnbind-hostNameBinding.bicep b/.azure/modules/hnbind-hostNameBinding.bicep new file mode 100644 index 0000000..e4f4e1d --- /dev/null +++ b/.azure/modules/hnbind-hostNameBinding.bicep @@ -0,0 +1,41 @@ + +@description('The fully qualified domain name (FQDN) to bind to the App Service. Must be 1-128 characters.') +@minLength(1) +@maxLength(128) +param fqdn string + +@description('The headless domain name to bind to the App Service. Must be 1-128 characters.') +@minLength(1) +@maxLength(128) +param headlessDn string + +@description('The name of the App Service site. Must be 1-128 characters.') +@minLength(1) +@maxLength(128) +param siteName string + +@description('The certificate thumbprint for SSL binding. Must be 1-256 characters.') +@minLength(1) +@maxLength(256) +param thumbprint string + +var deployFqdn = (empty(fqdn) ? bool('false') : bool('true')) +var deployHeadlessDn = (empty(headlessDn) ? bool('false') : bool('true')) + +resource siteName_fqdn 'Microsoft.Web/sites/hostNameBindings@2023-12-01' = if (deployFqdn) { + name: '${siteName}/${fqdn}' + properties: { + siteName: siteName + sslState: 'SniEnabled' + thumbprint: thumbprint + } +} + +resource siteName_headlessDn 'Microsoft.Web/sites/hostNameBindings@2023-12-01' = if (deployHeadlessDn) { + name: '${siteName}/${headlessDn}' + properties: { + siteName: siteName + sslState: 'SniEnabled' + thumbprint: thumbprint + } +} diff --git a/.azure/modules/kv-keyvault.bicep b/.azure/modules/kv-keyvault.bicep new file mode 100644 index 0000000..0bbcd3e --- /dev/null +++ b/.azure/modules/kv-keyvault.bicep @@ -0,0 +1,70 @@ + +@description('The name of the Key Vault. Must be 3-24 characters, using only alphanumeric characters and hyphens.') +@minLength(3) +@maxLength(24) +param name string + +@description('The Azure region where the Key Vault will be deployed.') +param location string = resourceGroup().location + +@description('The SKU (pricing tier) for the Key Vault. Allowed values: standard, premium. Default is standard.') +@allowed([ + 'standard' + 'premium' +]) +param sku string = 'standard' + +@description('The tenant ID of the Azure Active Directory that will be used for authentication.') +@minLength(36) +@maxLength(36) +param tenantId string = tenant().tenantId + +@description('Tags to apply to the Key Vault resource.') +param tags object = {} + +@description('Access policies to assign to the Key Vault.') +param accessPolicies array = [] + +@description('Enable RBAC authorization for the Key Vault. Default is true.') +param enableRbacAuthorization bool = true + +@description('List of allowed IP addresses for Key Vault access. Default is empty (no IPs allowed).') +param allowedIpRules array = [] + +@description('List of allowed Virtual Network resource IDs for Key Vault access. Default is empty (no VNets allowed).') +param allowedVirtualNetworkResourceIds array = [] + +@description('Enable soft delete for the Key Vault. Default is true.') +param enableSoftDelete bool = true + +@description('Enable purge protection for the Key Vault. Default is true.') +param enablePurgeProtection bool = true + +resource kvResource 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: name + location: location + tags: empty(tags) ? null : tags + properties: { + enabledForDeployment: true + enabledForDiskEncryption: true + enabledForTemplateDeployment: true + tenantId: tenantId + publicNetworkAccess: 'Disabled' + sku: { + name: sku + family: 'A' + } + accessPolicies: accessPolicies == [] && enableRbacAuthorization == true ? null : accessPolicies + enableRbacAuthorization: enableRbacAuthorization + enableSoftDelete: enableSoftDelete + enablePurgeProtection: enablePurgeProtection + networkAcls: { + defaultAction: 'Deny' + bypass: 'AzureServices' + virtualNetworkRules: allowedVirtualNetworkResourceIds + ipRules: allowedIpRules + } + } +} + +output id string = kvResource.id diff --git a/.azure/modules/luis-languageunderstanding.bicep b/.azure/modules/luis-languageunderstanding.bicep new file mode 100644 index 0000000..219229d --- /dev/null +++ b/.azure/modules/luis-languageunderstanding.bicep @@ -0,0 +1,63 @@ + +@description('The name of the LUIS Cognitive Service resource. Must be 2-64 characters, using only alphanumeric characters and hyphens.') +@minLength(2) +@maxLength(64) +param name string + +@description('The Azure region where the LUIS Cognitive Service resource will be deployed.') +param location string = resourceGroup().location + + +@description('The SKU (pricing tier) for the LUIS Cognitive Service resource. Allowed values: F0, S0. Default is F0.') +@allowed([ + 'F0' + 'S0' +]) +param sku string = 'F0' + +@description('The name of the LUIS Authoring resource. Must be 2-64 characters, using only alphanumeric characters and hyphens.') +@minLength(2) +@maxLength(64) +param authoringName string + + +@description('The Azure region for the LUIS Authoring resource. Allowed values: westus, eastus. Default is westus.') +@allowed([ + 'westus' + 'eastus' +]) +param authoringLocation string = 'westus' + + +@description('The SKU (pricing tier) for the LUIS Authoring resource. Allowed value: F0. Default is F0.') +@allowed([ + 'F0' +]) +param authoringSku string = 'F0' + +resource name_resource 'Microsoft.CognitiveServices/accounts@2023-05-01' = { + name: name + location: location + kind: 'LUIS' + sku: { + name: sku + } + properties: { + customSubDomainName: name + } +} + +resource authoringName_resource 'Microsoft.CognitiveServices/accounts@2023-05-01' = { + name: authoringName + location: authoringLocation + kind: 'LUIS.Authoring' + sku: { + name: authoringSku + } + properties: { + customSubDomainName: authoringName + } + dependsOn: [ + name_resource + ] +} diff --git a/.azure/modules/nsg-networksecuritygroup.bicep b/.azure/modules/nsg-networksecuritygroup.bicep new file mode 100644 index 0000000..f1b6426 --- /dev/null +++ b/.azure/modules/nsg-networksecuritygroup.bicep @@ -0,0 +1,33 @@ +@description('Array of NSG security rules to apply to this NSG') +param securityRules array = [] +@description('The name of the Network Security Group (NSG)') +param name string + +@description('Tags to apply to the NSG') +param tags object = {} + +@description('Location for the NSG') +param location string = resourceGroup().location + +resource nsgResource 'Microsoft.Network/networkSecurityGroups@2023-05-01' = { + name: name + location: location + tags: tags +} + +resource rule 'Microsoft.Network/networkSecurityGroups/securityRules@2023-05-01' = [for ruleObj in securityRules: { + name: ruleObj.name + parent: nsgResource + properties: { + priority: ruleObj.priority + direction: ruleObj.direction + access: ruleObj.access + protocol: ruleObj.protocol + sourcePortRange: ruleObj.sourcePortRange + destinationPortRange: ruleObj.destinationPortRange + sourceAddressPrefix: ruleObj.sourceAddressPrefix + destinationAddressPrefix: ruleObj.destinationAddressPrefix + } +}] + +output id string = nsgResource.id diff --git a/.azure/modules/nsgrule-networksecuritygrouprule.bicep b/.azure/modules/nsgrule-networksecuritygrouprule.bicep new file mode 100644 index 0000000..7391880 --- /dev/null +++ b/.azure/modules/nsgrule-networksecuritygrouprule.bicep @@ -0,0 +1,26 @@ +@description('The name of the Network Security Group (NSG)') +param nsgName string + +@description('Array of NSG security rules to apply to this NSG') +param securityRules array = [] + +resource nsgResource 'Microsoft.Network/networkSecurityGroups@2023-05-01' existing = { + name: nsgName +} + +resource rule 'Microsoft.Network/networkSecurityGroups/securityRules@2023-05-01' = [for ruleObj in securityRules: { + name: ruleObj.name + parent: nsgResource + properties: { + priority: ruleObj.priority + direction: ruleObj.direction + access: ruleObj.access + protocol: ruleObj.protocol + sourcePortRange: ruleObj.sourcePortRange + destinationPortRange: ruleObj.destinationPortRange + sourceAddressPrefix: ruleObj.sourceAddressPrefix + destinationAddressPrefix: ruleObj.destinationAddressPrefix + } +}] + +output id string = nsgResource.id diff --git a/.azure/modules/o365cn-o365apiconnection.bicep b/.azure/modules/o365cn-o365apiconnection.bicep new file mode 100644 index 0000000..db7de5f --- /dev/null +++ b/.azure/modules/o365cn-o365apiconnection.bicep @@ -0,0 +1,28 @@ + +@description('The name of the Office 365 API Connection. Must be 1-80 characters, using only alphanumeric characters and hyphens. Default is teams.') +@minLength(1) +@maxLength(80) +param name string = 'teams' + +var locationLower = toLower(replace(resourceGroup().location, ' ', '')) +var nameLower = toLower(replace(replace(name, '-', ''), ' ', '')) + +resource connection 'Microsoft.Web/connections@2016-06-01' = { + name: nameLower + location: locationLower + properties: { + displayName: name + customParameterValues: {} + api: { + name: nameLower + displayName: 'Office 365' + description: 'Microsoft Teams enables you to get all your content, tools and conversations in the Team workspace with Office 365.' + iconUri: 'https://connectoricons-prod.azureedge.net/releases/v1.0.1505/1.0.1505.2520/${nameLower}/icon.png' + brandColor: '#4B53BC' + id: '${subscription().id}/providers/Microsoft.Web/locations/${resourceGroup().location}/managedApis/office365' + type: 'Microsoft.Web/locations/managedApis' + } + parameterValues: {} + } + dependsOn: [] +} diff --git a/.azure/modules/plan-appserviceplan.bicep b/.azure/modules/plan-appserviceplan.bicep new file mode 100644 index 0000000..5cd63cf --- /dev/null +++ b/.azure/modules/plan-appserviceplan.bicep @@ -0,0 +1,87 @@ +@description('The name of the App Service Plan. Must be 1-40 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(40) +param name string + +@description('The Azure region where the App Service Plan will be deployed.') +param location string + +@description('The SKU (pricing tier) for the App Service Plan. Allowed values: F1, D1, B1, B2, B3, S1, S2, S3, P1, P2, P3, P4, Y1. Default is F1.') +@allowed([ + 'F1' + 'D1' + 'B1' + 'B2' + 'B3' + 'S1' + 'S2' + 'S3' + 'P1' + 'P2' + 'P3' + 'P4' + 'Y1' +]) +param sku string = 'F1' + +@description('Tags to apply to the App Service Plan resource.') +param tags object = {} + +@description('The OS type for the App Service Plan. Allowed values: Windows, Linux.') +@allowed([ + 'Windows' + 'Linux' +]) +param osType string = 'Windows' + +@description('Enable zone redundancy for the App Service Plan (PremiumV2 and higher only).') +param zoneRedundant bool = false + +@description('The number of worker instances.') +@minValue(1) +param capacity int = 1 + +@description('Enable per-site scaling (dedicated plans only).') +param perSiteScaling bool = false + +@description('Enable elastic scale (Premium plans only).') +param elasticScaleEnabled bool = false + +@description('Indicates if the plan is reserved for Linux (true) or Windows (false).') +param reserved bool = (osType == 'Linux') + +@description('Enable diagnostics settings for the App Service Plan.') +param enableDiagnostics bool = false + +@description('Diagnostics settings configuration (if enabled).') +param diagnosticsSettings object = {} + +resource planResource 'Microsoft.Web/serverfarms@2023-01-01' = { + name: name + location: location + kind: osType + tags: empty(tags) ? null : tags + sku: { + name: sku + capacity: capacity + } + properties: { + reserved: reserved + perSiteScaling: perSiteScaling + elasticScaleEnabled: elasticScaleEnabled + zoneRedundant: zoneRedundant + } +} + +resource diag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagnostics) { + name: '${planResource.name}-diagnostics' + scope: planResource + properties: diagnosticsSettings +} + +output id string = planResource.id +output name string = planResource.name +output location string = planResource.location +output kind string = planResource.kind +output sku object = planResource.sku +output properties object = planResource.properties diff --git a/.azure/modules/qna-qnamaker.bicep b/.azure/modules/qna-qnamaker.bicep new file mode 100644 index 0000000..22d3931 --- /dev/null +++ b/.azure/modules/qna-qnamaker.bicep @@ -0,0 +1,137 @@ +param name string + +@allowed([ + 'F0' + 'S0' +]) +param sku string = 'F0' + +@allowed([ + 'westus' + 'eastus' +]) +param location string = 'westus' +param location2 string = resourceGroup().location +param location3 string = resourceGroup().location +param azureSearchLocation string = resourceGroup().location + +@allowed([ + 'free' + 'basic' + 'standard' +]) +param azureSearchSku string = 'free' + +@allowed([ + 'Default' +]) +param searchHostingMode string = 'Default' + +@allowed([ + 'F0' + 'S1' +]) +param farmSku string = 'F0' + +var puredAzureSearchName = replace(name, '-', '') +var normalizedAzureSearchName = ((length(puredAzureSearchName) > 40) ? substring(puredAzureSearchName, (length(puredAzureSearchName) - 40), 40) : puredAzureSearchName) +var azureSearchName_var = toLower('srch-${normalizedAzureSearchName}') +var appiName = 'appi-${name}' + +resource name_resource 'Microsoft.CognitiveServices/accounts@2023-05-01' = { + kind: 'QnAMaker' + name: name + location: location + sku: { + name: sku + } + properties: { + apiProperties: { + qnaRuntimeEndpoint: 'https://${Microsoft_Web_sites_name.properties.hostNames[0]}' + } + customSubDomainName: name + } +} + +resource azureSearchName 'Microsoft.Search/searchServices@2023-11-01' = { + name: azureSearchName_var + location: azureSearchLocation + tags: {} + properties: { + replicaCount: 1 + partitionCount: 1 + hostingMode: searchHostingMode + } + sku: { + name: azureSearchSku + } +} + +resource Microsoft_Web_sites_name 'Microsoft.Web/sites@2023-12-01' = { + name: name + location: location3 + properties: { + enabled: true + siteConfig: { + cors: { + allowedOrigins: [ + '*' + ] + } + } + name: name + serverFarmId: '/subscriptions/${subscription().subscriptionId}/resourcegroups/${resourceGroup().name}/providers/Microsoft.Web/serverfarms/${name}' + hostingEnvironment: '' + } + tags: { + 'hidden-related:/subscriptions/${subscription().subscriptionId}/resourcegroups/${resourceGroup().name}/providers/Microsoft.Web/serverfarms/${name}': 'empty' + } + dependsOn: [ + Microsoft_Web_serverfarms_name + ] +} + +resource name_appiName 'Microsoft.Web/sites/microsoft.insights/components@2015-05-01' = { + name: '${name}/${appiName}' + kind: 'web' + location: location2 + tags: { + 'hidden-link:${Microsoft_Web_sites_name.id}': 'Resource' + } + properties: { + ApplicationId: name + } +} + +resource name_appsettings 'Microsoft.Web/sites/config@2021-06-01' = { + parent: Microsoft_Web_sites_name + name: 'appsettings' + properties: { + AzureSearchName: azureSearchName_var + AzureSearchAdminKey: listAdminKeys(azureSearchName.id, '2015-08-19').primaryKey + UserappiKey: reference(resourceId('microsoft.insights/components/', appiName), '2015-05-01').InstrumentationKey + UserappiName: appiName + UserAppInsightsAppId: reference(resourceId('microsoft.insights/components/', appiName), '2015-05-01').AppId + PrimaryEndpointKey: '${name}-PrimaryEndpointKey' + SecondaryEndpointKey: '${name}-SecondaryEndpointKey' + DefaultAnswer: 'No good match found in KB.' + QNAMAKER_EXTENSION_VERSION: 'latest' + } +} + +resource Microsoft_Web_serverfarms_name 'Microsoft.Web/serverfarms@2016-09-01' = { + name: name + location: location3 + properties: { + name: name + workerSizeId: '0' + reserved: false + numberOfWorkers: '1' + hostingEnvironment: '' + } + sku: { + name: farmSku + } +} + +output qnaRuntimeEndpoint string = 'https://${Microsoft_Web_sites_name.properties.hostNames[0]}' diff --git a/.azure/modules/redis-rediscache.bicep b/.azure/modules/redis-rediscache.bicep new file mode 100644 index 0000000..f34ef4b --- /dev/null +++ b/.azure/modules/redis-rediscache.bicep @@ -0,0 +1,76 @@ +@description('Specify the name of the Azure Redis Cache to create.') +param name string = 'redis-${uniqueString(resourceGroup().id)}' + +@description('Location of all resources') +param location string = toLower(replace(resourceGroup().location, ' ', '')) + +@description('Specify the pricing tier of the new Azure Redis Cache.') +@allowed([ + 'Basic' + 'Standard' + 'Premium' +]) +param sku string = 'Basic' + +@description('Specify the family for the sku. C = Basic/Standard, P = Premium.') +@allowed([ + 'C' + 'P' +]) +param family string = 'C' + +@description('Specify the size of the new Azure Redis Cache instance. Valid values: for C (Basic/Standard) family (0, 1, 2, 3, 4, 5, 6), for P (Premium) family (1, 2, 3, 4)') +@allowed([ + 0 + 1 + 2 + 3 + 4 + 5 + 6 +]) +param capacity int = 1 + +@description('Specify a boolean value that indicates whether to allow access via non-SSL ports.') +param enableNonSslPort bool = false + +@description('Specify a boolean value that indicates whether diagnostics should be saved to the specified storage account.') +param enableDiagnostics bool = false + +@description('Specify the name of an existing storage account for diagnostics.') +param stName string + +@description('Specify the resource group name of an existing storage account for diagnostics.') +param storageResourceGroup string = resourceGroup().name + +resource name_resource 'Microsoft.Cache/redis@2020-06-01' = { + name: name + location: location + properties: { + enableNonSslPort: enableNonSslPort + minimumTlsVersion: '1.2' + sku: { + capacity: capacity + family: family + name: sku + } + } +} + +resource microsoft_insights_diagnosticSettings_name 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + scope: name_resource + name: name + properties: { + storageAccountId: extensionResourceId('/subscriptions/${subscription().subscriptionId}/resourceGroups/${storageResourceGroup}', 'Microsoft.Storage/storageAccounts', stName) + metrics: [ + { + timeGrain: 'AllMetrics' + enabled: enableDiagnostics + retentionPolicy: { + days: 90 + enabled: enableDiagnostics + } + } + ] + } +} diff --git a/.azure/modules/relay-relaynamespace.bicep b/.azure/modules/relay-relaynamespace.bicep new file mode 100644 index 0000000..2cbaf75 --- /dev/null +++ b/.azure/modules/relay-relaynamespace.bicep @@ -0,0 +1,45 @@ + +@description('The name of the Azure Relay namespace. Must be 6-50 characters, using only alphanumeric characters and hyphens.') +@minLength(6) +@maxLength(50) +param name string + +@description('The Azure region where the Relay namespace will be deployed.') +param location string = resourceGroup().location + +@description('The SKU (pricing tier) for the Azure Relay namespace. Allowed value: Standard. Default is Standard.') +@allowed([ + 'Standard' +]) +param sku string = 'Standard' + +resource name_resource 'Microsoft.Relay/namespaces@2018-01-01-preview' = { + name: name + location: location + sku: { + name: sku + tier: sku + } + properties: {} +} + +resource name_RootManageSharedAccessKey 'Microsoft.Relay/namespaces/authorizationRules@2021-11-01' = { + parent: name_resource + name: 'RootManageSharedAccessKey' + properties: { + rights: [ + 'Listen' + 'Manage' + 'Send' + ] + } +} + +resource name_default 'Microsoft.Relay/namespaces/networkRuleSets@2021-11-01' = { + parent: name_resource + name: 'default' + properties: { + defaultAction: 'Deny' + ipRules: [] + } +} diff --git a/.azure/modules/rg-resourcegroup.bicep b/.azure/modules/rg-resourcegroup.bicep new file mode 100644 index 0000000..bff3347 --- /dev/null +++ b/.azure/modules/rg-resourcegroup.bicep @@ -0,0 +1,19 @@ +targetScope='subscription' + + +@description('The name of the Resource Group. Must be 1-90 characters, using only alphanumeric characters, hyphens, underscores, parentheses, and periods.') +@minLength(1) +@maxLength(90) +param name string + +@description('The Azure region where the Resource Group will be deployed.') +param location string + +@description('Tags to apply to the Resource Group.') +param tags object = {} + +resource rgResource 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: name + location: location + tags: empty(tags) ? null : tags +} diff --git a/.azure/modules/sb-servicebus.bicep b/.azure/modules/sb-servicebus.bicep new file mode 100644 index 0000000..57584cb --- /dev/null +++ b/.azure/modules/sb-servicebus.bicep @@ -0,0 +1,53 @@ + +@description('The name of the Service Bus namespace. Must be 6-50 characters, using only alphanumeric characters and hyphens.') +@minLength(6) +@maxLength(50) +param name string + +@description('The SKU (pricing tier) for the Service Bus namespace. Allowed values: Basic, Standard, Premium. Default is Basic.') +@allowed([ + 'Basic' + 'Standard' + 'Premium' +]) +param sku string = 'Basic' + + +@description('Specifies the Azure location where the Service Bus namespace should be created.') +param location string = toLower(replace(resourceGroup().location, ' ', '')) + +var nameAlphanumeric = replace(replace(name, '-', ''), '.', '') + +resource namespace 'Microsoft.ServiceBus/namespaces@2021-11-01' = { + name: nameAlphanumeric + location: location + sku: { + name: sku + tier: sku + } + properties: { + zoneRedundant: false + } +} + +resource authrule 'Microsoft.ServiceBus/namespaces/AuthorizationRules@2021-11-01' = { + parent: namespace + name: 'RootManageSharedAccessKey' + properties: { + rights: [ + 'Listen' + 'Manage' + 'Send' + ] + } +} + +resource netruleset 'Microsoft.ServiceBus/namespaces/networkRuleSets@2021-11-01' = if (sku == 'Premium') { + parent: namespace + name: 'default' + properties: { + defaultAction: 'Deny' + virtualNetworkRules: [] + ipRules: [] + } +} diff --git a/.azure/modules/sent-loganalyticsworkspace.bicep b/.azure/modules/sent-loganalyticsworkspace.bicep new file mode 100644 index 0000000..7118bd7 --- /dev/null +++ b/.azure/modules/sent-loganalyticsworkspace.bicep @@ -0,0 +1,45 @@ + +@description('The name of the Log Analytics workspace. Must be 4-63 characters, using only letters, numbers, and hyphens.') +@minLength(4) +@maxLength(63) +param name string + +@description('The Azure region where the Log Analytics workspace will be deployed.') +param location string + +@description('The SKU (pricing tier) for the Log Analytics workspace. Allowed values: Free, PerGB2018, CapacityReservation. Default is PerGB2018.') +@allowed([ + 'Free' + 'PerGB2018' + 'CapacityReservation' +]) +param sku string = 'PerGB2018' + +@description('Tags to apply to the Log Analytics workspace resource.') +param tags object = {} + +@description('The data retention period in days. Minimum is 30. Default is 30.') +@minValue(30) +param retentionInDays int = 30 + +resource workResource 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: name + location: location + tags: empty(tags) ? null : tags + properties: { + sku: { + name: sku + } + retentionInDays: retentionInDays + } +} + + +resource onboarding 'Microsoft.SecurityInsights/onboardingStates@2021-03-01-preview' = { + name: 'default' + scope: workResource + properties: {} +} + +output id string = workResource.id +output onboardingId string = onboarding.id diff --git a/.azure/modules/sftpcn-sftpsshapiconnection.bicep b/.azure/modules/sftpcn-sftpsshapiconnection.bicep new file mode 100644 index 0000000..2e4c800 --- /dev/null +++ b/.azure/modules/sftpcn-sftpsshapiconnection.bicep @@ -0,0 +1,76 @@ + +@description('The name of the SFTP SSH API Connection. Must be 1-80 characters, using only alphanumeric characters and hyphens. Default is azureblob.') +@minLength(1) +@maxLength(80) +param name string = 'azureblob' + +@description('The address of the SFTP server.') +@minLength(1) +@maxLength(255) +param ftpServerAddress string + +@description('The port of the SFTP server. Default is 22.') +@minLength(1) +@maxLength(5) +param ftpServerPort string = '22' + +@description('The username for the SFTP server.') +@minLength(1) +@maxLength(128) +param ftpUsername string + +@description('The password for the SFTP server.') +@minLength(1) +@maxLength(128) +@secure() +param ftpPassword string + +@description('The root folder for the SFTP connection. Default is /.') +@minLength(1) +@maxLength(255) +param ftpRootFolder string = '/' + +@description('SSH private key (the content of the file should be provided entirely as is, in the multiline format)') +@secure() +param ftpPrivateKey string = '' + +@description('SSH private key passphrase (if the private key is protected by a passphrase)') +@secure() +param ftpPassphrase string = '' + +@description('Disable SSH host key validation? (True/False)') +param ftpAcceptAnySshKey bool = true + +@description('SSH host key finger-print') +param ftpHostKeyFingerprint string = '' + +var locationShortName = toLower(replace(resourceGroup().location, ' ', '')) +var nameLower = toLower(replace(replace(name, '-', ''), ' ', '')) + +resource connection 'Microsoft.Web/connections@2016-06-01' = { + name: nameLower + location: locationShortName + properties: { + api: { + id: '${subscription().id}/providers/Microsoft.Web/locations/${locationShortName}/managedApis/sftpwithssh' + type: 'Microsoft.Web/locations/managedApis' + name: '${nameLower}sftpwithssh' + displayName: 'SFTP - SSH' + description: 'SFTP (SSH File Transfer Protocol) is a network protocol that provides file access, file transfer, and file management over any reliable data stream. It was designed by the Internet Engineering Task Force (IETF) as an extension of the Secure Shell protocol (SSH) version 2.0 to provide secure file transfer capabilities.' + iconUri: 'https://connectoricons-prod.azureedge.net/releases/v1.0.1518/1.0.1518.2564/sftpwithssh/icon.png' + brandColor: '#e8bb00' + } + displayName: name + parameterValues: { + hostName: ftpServerAddress + userName: ftpUsername + password: ftpPassword + sshPrivateKey: ftpPrivateKey + sshPrivateKeyPassphrase: ftpPassphrase + portNumber: ftpServerPort + acceptAnySshHostKey: string(ftpAcceptAnySshKey) + sshHostKeyFingerprint: ftpHostKeyFingerprint + rootFolder: ftpRootFolder + } + } +} diff --git a/.azure/modules/sjc-schedulejobcollection.bicep b/.azure/modules/sjc-schedulejobcollection.bicep new file mode 100644 index 0000000..b1d97fe --- /dev/null +++ b/.azure/modules/sjc-schedulejobcollection.bicep @@ -0,0 +1,80 @@ + +@description('The name of the Job Collection. Must be 1-60 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(60) +param name string + +@description('The Azure region where the Job Collection will be deployed.') +@minLength(1) +@maxLength(60) +param location string = toLower(replace(resourceGroup().location, ' ', '')) + +@description('The SKU (pricing tier) for the Job Collection. Allowed value: Standard. Default is Standard.') +@allowed([ + 'Standard' +]) +param sku string = 'Standard' + +@description('The name of the associated Web App. Must be 1-60 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(60) +param webName string + +@description('The maximum number of jobs allowed in the Job Collection. Default is 10.') +@minValue(1) +param maxJobs int = 10 + +@description('The interval for the job recurrence. Default is 5.') +@minValue(1) +param timerInterval int = 5 + +@description('The frequency for the job recurrence. Default is minute.') +param timerFrequency string = 'minute' + +@description('The start time for the scheduled job. Default is the current UTC time.') +param startTime string = utcNow() + +var nameLower = toLower(replace(replace(name, '-', ''), ' ', '')) + +resource name_resource 'Microsoft.Scheduler/jobCollections@2016-03-01' = { + name: name + location: location + properties: { + sku: { + name: sku + } + quota: { + maxJobCount: maxJobs + maxRecurrence: { + frequency: timerFrequency + interval: timerInterval + } + } + } + dependsOn: [] +} + +resource name_nameLower 'Microsoft.Scheduler/jobCollections/jobs@2014-08-01-preview' = { + parent: name_resource + name: '${nameLower}' + properties: { + startTime: startTime + action: { + request: { + uri: '${list(resourceId('Microsoft.Web/sites/config', webName, 'publishingcredentials'), '2014-06-01').properties.scmUri}/api/triggeredjobs/MyScheduledWebJob/run' + method: 'POST' + } + type: 'Http' + retryPolicy: { + retryType: 'Fixed' + retryInterval: 'PT1M' + retryCount: 2 + } + } + state: 'Enabled' + recurrence: { + frequency: timerFrequency + interval: timerInterval + } + } +} diff --git a/.azure/modules/snet-virtualnetworksubnet.bicep b/.azure/modules/snet-virtualnetworksubnet.bicep new file mode 100644 index 0000000..66da69c --- /dev/null +++ b/.azure/modules/snet-virtualnetworksubnet.bicep @@ -0,0 +1,23 @@ +@description('Name of the existing virtual network to add the subnet to') +param vnetName string + +@description('Name of the subnet to create') +param snetName string + +@description('Address prefix for the subnet (e.g., 10.0.0.0/24)') +param cidr string + +@description('Resource ID of the Network Security Group to associate with the subnet') +param nsgId string = '' + +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = { + name: '${vnetName}/${snetName}' + properties: { + addressPrefix: cidr + networkSecurityGroup: empty(nsgId) ? null : { + id: nsgId + } + } +} + +output id string = subnet.id diff --git a/.azure/modules/spocn-sharepointonlineapiconnection.bicep b/.azure/modules/spocn-sharepointonlineapiconnection.bicep new file mode 100644 index 0000000..52eaf6b --- /dev/null +++ b/.azure/modules/spocn-sharepointonlineapiconnection.bicep @@ -0,0 +1,34 @@ + +@description('The name of the SharePoint Online API Connection. Must be 1-80 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(80) +param name string + +@description('The Azure AD tenant ID for the SharePoint Online connection. Defaults to the current subscription tenant.') +param tenantId string = subscription().tenantId + +var locationShortName = toLower(replace(resourceGroup().location, ' ', '')) +var nameLower = toLower(replace(replace(name, '-', ''), ' ', '')) + +resource connection 'Microsoft.Web/connections@2016-06-01' = { + name: nameLower + location: locationShortName + properties: { + displayName: name + customParameterValues: {} + api: { + name: nameLower + displayName: 'SharePoint' + description: 'SharePoint helps organizations share and collaborate with colleagues, partners, and customers. You can connect to SharePoint Online or to an on-premises SharePoint 2013 or 2016 farm using the On-Premises Data Gateway to manage documents and list items.' + iconUri: 'https://connectoricons-prod.azureedge.net/releases/v1.0.1533/1.0.1533.2600/${nameLower}/icon.png' + brandColor: '#036C70' + id: '${subscription().id}/providers/Microsoft.Web/locations/${locationShortName}/managedApis/sharepointonline' + type: 'Microsoft.Web/locations/managedApis' + } + nonSecretParameterValues: { + 'token:TenantId': tenantId + } + parameterValues: {} + } + dependsOn: [] +} diff --git a/.azure/modules/sql-sqlserver.bicep b/.azure/modules/sql-sqlserver.bicep new file mode 100644 index 0000000..2ccb2bd --- /dev/null +++ b/.azure/modules/sql-sqlserver.bicep @@ -0,0 +1,53 @@ + +@description('The name of the SQL Server. Must be 1-60 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(60) +param name string + +@description('The Azure region where the SQL Server will be deployed.') +param location string = resourceGroup().location + +@description('Tags to apply to the SQL Server resource.') +param tags object = {} + +@description('The administrator login name for the SQL Server. Must be 1-60 characters.') +@minLength(1) +@maxLength(60) +param adminLogin string + +@description('The administrator password for the SQL Server. Must be 1-128 characters.') +@minLength(1) +@maxLength(128) +@secure() +param adminPassword string + +@description('The starting IP address for the SQL Server firewall rule. Default is 0.0.0.0.') +param startIpAddress string = '0.0.0.0' + +@description('The ending IP address for the SQL Server firewall rule. Default is 0.0.0.0.') +param endIpAddress string = '0.0.0.0' + + +var nameLower = toLower(name) + +resource sqlServer 'Microsoft.Sql/servers@2023-08-01-preview' = { + name: nameLower + location: location + tags: empty(tags) ? null : tags + properties: { + administratorLogin: adminLogin + administratorLoginPassword: adminPassword + } +} + +resource sqlServerFirewall 'Microsoft.Sql/servers/firewallRules@2023-08-01-preview' = { + parent: sqlServer + name: 'AllowAllWindowsAzureIps' + properties: { + endIpAddress: endIpAddress + startIpAddress: startIpAddress + } +} + +output id string = sqlServer.id +output name string = sqlServer.name diff --git a/.azure/modules/sql-sqlserverdatabase.bicep b/.azure/modules/sql-sqlserverdatabase.bicep new file mode 100644 index 0000000..0e37952 --- /dev/null +++ b/.azure/modules/sql-sqlserverdatabase.bicep @@ -0,0 +1,95 @@ + +// Sql Server +@description('The name of the SQL Server. Must be 1-60 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(60) +param name string + +@description('The Azure region where the SQL Server will be deployed.') +param location string = resourceGroup().location + +@description('Tags to apply to the SQL Server resource.') +param tags object = {} + +@description('The administrator login name for the SQL Server. Must be 1-60 characters.') +@minLength(1) +@maxLength(60) +param adminLogin string + +@description('The administrator password for the SQL Server. Must be 1-128 characters.') +@minLength(1) +@maxLength(128) +@secure() +param adminPassword string + +@description('The starting IP address for the SQL Server firewall rule.') +param startIpAddress string = '0.0.0.0' + +@description('The ending IP address for the SQL Server firewall rule.') +param endIpAddress string = '0.0.0.0' + +// Sql Database +@description('The name of the SQL Database. Must be 1-60 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(60) +param sqldbName string + +@description('The compute capacity for the SQL Database SKU. Default is 5.') +@minValue(1) +param sqlCapacity int = 5 + +@description('The collation for the SQL Database. Default is SQL_Latin1_General_CP1_CI_AS.') +param collation string = 'SQL_Latin1_General_CP1_CI_AS' + +@description('The SKU (pricing tier) for the SQL Database. Allowed values: Basic, Standard, Premium. Default is Basic.') +@allowed([ + 'Basic' + 'Standard' + 'Premium' +]) +param sku string = 'Basic' + +@description('The maximum size of the SQL Database in bytes. Default is 1073741824 (1 GB).') +@minValue(1048576) +param maxSizeBytes int = 1073741824 + +resource sqlServer 'Microsoft.Sql/servers@2023-08-01-preview' = { + name: name + location: location + tags: empty(tags) ? null : tags + properties: { + administratorLogin: adminLogin + administratorLoginPassword: adminPassword + } +} + +resource sqlServerFirewall 'Microsoft.Sql/servers/firewallRules@2023-08-01-preview' = { + parent: sqlServer + name: 'AllowAllWindowsAzureIps' + properties: { + endIpAddress: endIpAddress + startIpAddress: startIpAddress + } +} + +output id string = sqlServer.id +output name string = sqlServer.name + +resource sqlDatabase 'Microsoft.Sql/servers/databases@2023-05-01-preview' = { + parent: sqlServer + name: sqldbName + location: location + tags: { + displayName: 'Database' + } + sku: { + name: sku + tier: sku // Replace with the desired SKU tier (e.g., Basic, GeneralPurpose, BusinessCritical) + //family: 'skuFamily' // Replace with the desired SKU family (e.g., Gen4, Gen5) + capacity: sqlCapacity // Replace with the desired capacity (e.g., 1, 2, 4) + } + properties: { + collation: collation + maxSizeBytes: maxSizeBytes + } +} diff --git a/.azure/modules/sqlcn-sqldgwconnection.bicep b/.azure/modules/sqlcn-sqldgwconnection.bicep new file mode 100644 index 0000000..0f158e3 --- /dev/null +++ b/.azure/modules/sqlcn-sqldgwconnection.bicep @@ -0,0 +1,82 @@ + +@description('The name of the SQL Data Gateway Connection. Must be 1-80 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(80) +param name string + +@description('The Azure region where the SQL Data Gateway Connection will be deployed.') +param location string = resourceGroup().location + +@description('The subscription ID for the connection. Defaults to the current subscription.') +param subscriptionId string = subscription().subscriptionId + +@description('The name of the Data Gateway.') +@minLength(1) +@maxLength(80) +param dgwName string + +@description('The resource group name of the Data Gateway.') +@minLength(1) +@maxLength(90) +param dgwResourceGroupName string + + +@description('The SQL authentication type. Allowed values: basic, windows. Default is windows.') +@allowed([ + 'basic' + 'windows' +]) +param sqlAuthType string = 'windows' + +@description('The name of the SQL Server.') +@minLength(1) +@maxLength(128) +param sqlServerName string + +@description('The name of the SQL Database.') +@minLength(1) +@maxLength(128) +param sqlDatabaseName string + +@description('The SQL user name.') +@minLength(1) +@maxLength(128) +param sqlUserName string + +@description('The SQL user password.') +@minLength(1) +@maxLength(128) +@secure() +param sqlUserPassword string + +@description('Whether to encrypt the SQL connection. Default is false.') +param encryptConnection bool = false + +@description('The privacy setting for the connection. Default is None.') +param privacySetting string = 'None' + +var locationShortName = toLower(replace(location, ' ', '')) + +resource conection 'Microsoft.Web/connections@2016-06-01' = { + name: name + location: locationShortName + properties: { + displayName: name + customParameterValues: {} + api: { + id: '/subscriptions/${subscriptionId}/providers/Microsoft.Web/locations/${locationShortName}/managedApis/sql' + } + parameterValues: { + server: sqlServerName + database: sqlDatabaseName + username: sqlUserName + password: sqlUserPassword + authType: sqlAuthType + encryptConnection: encryptConnection + privacySetting: privacySetting + gateway: { + id: '/subscriptions/${subscriptionId}/resourceGroups/${dgwResourceGroupName}/providers/Microsoft.Web/connectionGateways/${dgwName}' + } + } + } +} diff --git a/.azure/modules/sqldb-sqldatabase.bicep b/.azure/modules/sqldb-sqldatabase.bicep new file mode 100644 index 0000000..b1445e8 --- /dev/null +++ b/.azure/modules/sqldb-sqldatabase.bicep @@ -0,0 +1,51 @@ + +@description('The name of the SQL Database. Must be 1-60 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(60) +param name string + +@description('The Azure region where the SQL Database will be deployed.') +param location string = resourceGroup().location + +@description('Tags to apply to the SQL Database resource.') +param tags object = {} + +@description('The SKU (pricing tier) for the SQL Database. Allowed values: Basic, Standard, Premium. Default is Basic.') +@allowed([ + 'Basic' + 'Standard' + 'Premium' +]) +param sku string = 'Basic' + +@description('The compute capacity for the SQL Database SKU. Default is 5.') +@minValue(1) +param sqlCapacity int = 5 + +@description('The collation for the SQL Database. Default is SQL_Latin1_General_CP1_CI_AS.') +param collation string = 'SQL_Latin1_General_CP1_CI_AS' + +@description('The maximum size of the SQL Database in bytes. Default is 1073741824 (1 GB).') +@minValue(1048576) +param maxSizeBytes int = 1073741824 + +@description('The name of the parent SQL Server resource.') +@minLength(1) +@maxLength(60) +param sqlName string + +resource sqlDatabase 'Microsoft.Sql/servers/databases@2023-08-01-preview' = { + name: '${sqlName}/${name}' + location: location + tags: empty(tags) ? null : tags + sku: { + name: sku + tier: sku // (e.g., Basic, GeneralPurpose, BusinessCritical) + //family: 'skuFamily' // (e.g., Gen4, Gen5) + capacity: sqlCapacity // (e.g., 1, 2, 4) + } + properties: { + collation: collation + maxSizeBytes: maxSizeBytes + } +} diff --git a/.azure/modules/sqldb-sqlserverdatabase.bicep b/.azure/modules/sqldb-sqlserverdatabase.bicep new file mode 100644 index 0000000..141211a --- /dev/null +++ b/.azure/modules/sqldb-sqlserverdatabase.bicep @@ -0,0 +1,94 @@ +@minLength(1) +@maxLength(60) +param name string + +@minLength(1) +@maxLength(60) +param adminLogin string + +@minLength(1) +@maxLength(128) +@secure() +param adminPassword string + +@minLength(1) +@maxLength(60) +param sqldbName string +param collation string = 'SQL_Latin1_General_CP1_CI_AS' + +@allowed([ + 'Basic' + 'Standard' + 'Premium' +]) +param sku string = 'Basic' +param location string = toLower(replace(resourceGroup().location, ' ', '')) +param maxSizeBytes int = 1073741824 + +@description('Describes the performance level for Sku') +@allowed([ + 'Basic' + 'S0' + 'S1' + 'S2' + 'P1' + 'P2' + 'P3' +]) +param skuPerformanceLevel string = 'Basic' + +resource sqlServer 'Microsoft.Sql/servers@2021-11-01' = { + name: name + location: location + tags: { + displayName: 'SqlServer' + } + properties: { + administratorLogin: adminLogin + administratorLoginPassword: adminPassword + } +} + +resource sqlDatabase 'Microsoft.Sql/servers/databases@2023-05-01-preview' = { + parent: sqlServer + name: sqldbName + location: location + tags: { + displayName: 'Database' + } + sku: { + name: sku + //tier: 'skuTier' // Replace with the desired SKU tier (e.g., Basic, GeneralPurpose, BusinessCritical) + //family: 'skuFamily' // Replace with the desired SKU family (e.g., Gen4, Gen5) + capacity: 1 // Replace with the desired capacity (e.g., 1, 2, 4) + } + properties: { + collation: 'collation' + maxSizeBytes: maxSizeBytes + } +} + +// resource sqlDatabase 'Microsoft.Sql/servers/databases@2021-11-01' = { +// parent: sqlServer +// name: sqldbName +// location: location +// tags: { +// displayName: 'Database' +// } +// properties: { +// edition: sku +// collation: collation +// maxSizeBytes: maxSizeBytes +// skuPerformanceLevel: skuPerformanceLevel +// } +// } + +resource sqlFirewall 'Microsoft.Sql/servers/firewallRules@2021-11-01' = { + parent: sqlServer + location: resourceGroup().location + name: 'AllowAllWindowsAzureIps' + properties: { + endIpAddress: '0.0.0.0' + startIpAddress: '0.0.0.0' + } +} diff --git a/.azure/modules/st-storageaccount.bicep b/.azure/modules/st-storageaccount.bicep new file mode 100644 index 0000000..3fc2333 --- /dev/null +++ b/.azure/modules/st-storageaccount.bicep @@ -0,0 +1,73 @@ + +@description('The Azure region where the storage account will be deployed.') +param location string + +@description('Resource tags to apply to the storage account.') +param tags object = {} + +@description('The name of the storage account. Must be globally unique, 3-24 characters, using only lowercase letters and numbers.') +@minLength(3) +@maxLength(24) +param name string + +@description('The SKU (pricing tier) of the storage account. Allowed values: Standard_LRS, Standard_GRS, Standard_RAGRS, Standard_ZRS, Premium_LRS, Premium_ZRS. Default is Standard_LRS.') +@allowed([ + 'Standard_LRS' + 'Standard_GRS' + 'Standard_RAGRS' + 'Standard_ZRS' + 'Premium_LRS' + 'Premium_ZRS' +]) +param sku string = 'Standard_LRS' + + +@description('List of allowed IP addresses for Storage Account access. Default is empty (no IPs allowed).') +param allowedIpRules array = [] + +resource stResource 'Microsoft.Storage/storageAccounts@2023-01-01' = { + name: name + location: location + tags: empty(tags) ? null : tags + sku: { + name: sku + } + kind: 'StorageV2' + properties: { + allowBlobPublicAccess: false + supportsHttpsTrafficOnly: true + minimumTlsVersion: 'TLS1_2' + allowSharedKeyAccess: false + publicNetworkAccess: 'Disabled' + networkAcls: { + defaultAction: 'Deny' + bypass: 'AzureServices' + ipRules: allowedIpRules + virtualNetworkRules: [] + } + encryption: { + keySource: 'Microsoft.Storage' + requireInfrastructureEncryption: true + services: { + blob: { + enabled: true + keyType: 'Account' + } + file: { + enabled: true + keyType: 'Account' + } + queue: { + enabled: true + keyType: 'Service' + } + table: { + enabled: true + keyType: 'Service' + } + } + } + } +} + +output id string = stResource.id diff --git a/.azure/modules/stapp-staticwebapp.bicep b/.azure/modules/stapp-staticwebapp.bicep new file mode 100644 index 0000000..57ecfcf --- /dev/null +++ b/.azure/modules/stapp-staticwebapp.bicep @@ -0,0 +1,40 @@ +@description('The name of the Static Web App. Must be 1-40 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(40) +param name string + +@description('The Azure region where the Static Web App will be deployed.') +param location string = resourceGroup().location + +@description('The SKU (pricing tier) for the Static Web App. Allowed values: Free, Standard. Default is Free.') +@allowed([ + 'Free' + 'Standard' +]) +param sku string = 'Free' + +@description('Tags to add to the Static Web App resource.') +param tags object = {} + +@description('The Git repository URL for the Static Web App source code.') +@minLength(1) +param repositoryUrl string + +@description('The Git branch to deploy from. Default is main.') +@minLength(1) +param branch string = 'main' + +resource name_resource 'Microsoft.Web/staticSites@2022-09-01' = { + name: name + location: location + tags: empty(tags) ? null : tags + sku: { + tier: sku + name: sku + } + properties: { + repositoryUrl: repositoryUrl + branch: branch + } +} + diff --git a/.azure/modules/stblobcn-storageblobapiconnection.bicep b/.azure/modules/stblobcn-storageblobapiconnection.bicep new file mode 100644 index 0000000..471c666 --- /dev/null +++ b/.azure/modules/stblobcn-storageblobapiconnection.bicep @@ -0,0 +1,37 @@ + +@description('The name of the Blob Storage API Connection. Must be 1-80 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(80) +param name string + +@description('The name of the target Storage Account for the connection.') +@minLength(3) +@maxLength(24) +param stName string + +var locationShortName = toLower(replace(resourceGroup().location, ' ', '')) +var nameLower = toLower(replace(replace(name, '-', ''), ' ', '')) +var storageId = resourceId('Microsoft.Storage/storageAccounts', stName) + +resource connection 'Microsoft.Web/connections@2016-06-01' = { + name: nameLower + location: locationShortName + properties: { + displayName: name + customParameterValues: {} + api: { + name: '${nameLower}azureblob' + displayName: 'Azure Blob Storage' + description: 'Microsoft Azure Storage provides a massively scalable, durable, and highly available storage for data on the cloud, and serves as the data storage solution for modern applications. Connect to Blob Storage to perform various operations such as create, update, get and delete on blobs in your Azure Storage account.' + iconUri: 'https://connectoricons-prod.azureedge.net/releases/v1.0.1507/1.0.1507.2528/azureblob/icon.png' + brandColor: '#804998' + id: '/subscriptions/${subscription().subscriptionId}/providers/Microsoft.Web/locations/${locationShortName}/managedApis/azureblob' + type: 'Microsoft.Web/locations/managedApis' + } + parameterValues: { + accountName: stName + accessKey: listKeys(storageId, '2019-04-01').keys[0].value + } + } + dependsOn: [] +} diff --git a/.azure/modules/sttablecn-storagetablesapiconnection.bicep b/.azure/modules/sttablecn-storagetablesapiconnection.bicep new file mode 100644 index 0000000..918c133 --- /dev/null +++ b/.azure/modules/sttablecn-storagetablesapiconnection.bicep @@ -0,0 +1,36 @@ + +@description('The name of the Storage Tables API Connection. Must be 1-80 characters, using only alphanumeric characters and hyphens. Default is azureblob.') +@minLength(1) +@maxLength(80) +param name string = 'azureblob' + +@description('The name of the target Storage Account for the connection.') +@minLength(3) +@maxLength(24) +param stName string + +var locationShortName = toLower(replace(resourceGroup().location, ' ', '')) +var nameLower = toLower(replace(replace(name, '-', ''), ' ', '')) + +resource connection 'Microsoft.Web/connections@2016-06-01' = { + name: nameLower + location: locationShortName + properties: { + displayName: name + customParameterValues: {} + api: { + name: nameLower + displayName: 'Azure Storage Tables' + description: 'Microsoft Azure Storage provides a massively scalable, durable, and highly available storage for data on the cloud, and serves as the data storage solution for modern applications. Connect to Blob Storage to perform various operations such as create, update, get and delete on blobs in your Azure Storage account.' + iconUri: 'https://connectoricons-prod.azureedge.net/azuretables/icon_1.0.1048.1234.png' + brandColor: '#804998' + id: '/subscriptions/${subscription().subscriptionId}/providers/Microsoft.Web/locations/${locationShortName}/managedApis/azureblob' + type: 'Microsoft.Web/locations/managedApis' + } + parameterValues: { + accountName: stName + accessKey: listKeys(resourceId(subscription().subscriptionId, resourceGroup().name, 'Microsoft.Storage/storageAccounts', stName), '2021-06-01').keys[0].value + } + } + dependsOn: [] +} diff --git a/.azure/modules/teamscn-teamsapiconnection.bicep b/.azure/modules/teamscn-teamsapiconnection.bicep new file mode 100644 index 0000000..12fb3cf --- /dev/null +++ b/.azure/modules/teamscn-teamsapiconnection.bicep @@ -0,0 +1,28 @@ + +@description('The name of the Teams API Connection. Must be 1-80 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(80) +param name string + +var locationShortName = toLower(replace(resourceGroup().location, ' ', '')) +var nameLower = toLower(replace(replace(name, '-', ''), ' ', '')) + +resource connection 'Microsoft.Web/connections@2016-06-01' = { + name: nameLower + location: locationShortName + properties: { + displayName: name + customParameterValues: {} + api: { + name: nameLower + displayName: 'Microsoft Teams' + description: 'Microsoft Teams enables you to get all your content, tools and conversations in the Team workspace with Office 365.' + iconUri: 'https://connectoricons-prod.azureedge.net/releases/v1.0.1505/1.0.1505.2520/${nameLower}/icon.png' + brandColor: '#4B53BC' + id: '/subscriptions/${subscription().subscriptionId}/providers/Microsoft.Web/locations/${locationShortName}/managedApis/teams' + type: 'Microsoft.Web/locations/managedApis' + } + parameterValues: {} + } + dependsOn: [] +} diff --git a/.azure/modules/vnet-virtualnetwork.bicep b/.azure/modules/vnet-virtualnetwork.bicep new file mode 100644 index 0000000..cbba7cd --- /dev/null +++ b/.azure/modules/vnet-virtualnetwork.bicep @@ -0,0 +1,31 @@ + +@description('The name of the Virtual Network. Must be 2-64 characters, using only alphanumeric characters and hyphens.') +@minLength(2) +@maxLength(64) +param name string + +@description('The address prefix (CIDR block) for the Virtual Network. Example: 10.0.0.0/16') +@minLength(9) +@maxLength(18) +param addressPrefix string + +@description('The Azure region where the Virtual Network will be deployed.') +param location string + +@description('Tags to apply to the Virtual Network resource.') +param tags object = {} + +resource vnetResource 'Microsoft.Network/virtualNetworks@2023-02-01' = { + name: name + location: location + tags: empty(tags) ? null : tags + properties: { + addressSpace: { + addressPrefixes: [ addressPrefix ] + } + enableDdosProtection: false + enableVmProtection: false + } +} + +output id string = vnetResource.id diff --git a/.azure/modules/vnetpeer-virtualnetworkpeering.bicep b/.azure/modules/vnetpeer-virtualnetworkpeering.bicep new file mode 100644 index 0000000..c9f7f9b --- /dev/null +++ b/.azure/modules/vnetpeer-virtualnetworkpeering.bicep @@ -0,0 +1,36 @@ +@description('Name of the local virtual network (parent)') +param localVnetName string + +@description('Peering display name (local -> remote)') +param peeringName string + +@description('Remote VNet full resource ID') +param remoteVnetId string + +@description('Enable gateway transit (only if this is the gateway owner)') +param allowGatewayTransit bool = false + +@description('Use remote gateways (only if the remote allows gateway transit)') +param useRemoteGateways bool = false + +@description('Allow VNet access') +param allowVnetAccess bool = true + +@description('Allow forwarded traffic') +param allowForwardedTraffic bool = false + +resource localVnet 'Microsoft.Network/virtualNetworks@2024-09-01' existing = { + name: localVnetName +} + +resource localToRemote 'Microsoft.Network/virtualNetworks/virtualNetworkPeerings@2025-03-01' = { + name: peeringName + parent: localVnet + properties: { + remoteVirtualNetwork: { id: remoteVnetId } + allowVirtualNetworkAccess: allowVnetAccess + allowForwardedTraffic: allowForwardedTraffic + allowGatewayTransit: allowGatewayTransit + useRemoteGateways: useRemoteGateways + } +} diff --git a/.azure/modules/wcert-webcertificate.bicep b/.azure/modules/wcert-webcertificate.bicep new file mode 100644 index 0000000..e7c1ab8 --- /dev/null +++ b/.azure/modules/wcert-webcertificate.bicep @@ -0,0 +1,65 @@ + +@description('The name of the Web Certificate resource. Must be 1-80 characters.') +@minLength(1) +@maxLength(80) +param name string + +@description('Tags to apply to the Web Certificate resource.') +param tags object = {} + +@description('The password for the PFX certificate. Must be at least 8 characters.') +@minLength(8) +@secure() +param password string + +@description('The resource ID of the Key Vault containing the certificate secret.') +@minLength(1) +param keyVaultId string + +@description('The name of the secret in Key Vault containing the certificate.') +@minLength(1) +param keyVaultSecretName string + +@description('The resource ID of the App Service Plan (server farm).') +@minLength(1) +param serverFarmId string + +@description('The canonical name (CNAME) for domain validation.') +@minLength(1) +param canonicalName string + +@description('The domain validation method. Allowed values: email, dns, http. Default is dns.') +@allowed([ + 'email' + 'dns' + 'http' +]) +param domainValidationMethod string = 'dns' + +@description('The list of hostnames for the certificate.') +@minLength(1) +param hostnames array + +@description('The PFX certificate blob as a byte array.') +@minLength(1) +param pfxBlob array + +var location = resourceGroup().location + +resource name_resource 'Microsoft.Web/certificates@2023-12-01' = { + name: name + location: location + tags: empty(tags) ? null : tags + properties: { + hostNames: hostnames + pfxBlob: [ + pfxBlob + ] + password: password + keyVaultId: keyVaultId + keyVaultSecretName: keyVaultSecretName + serverFarmId: serverFarmId + canonicalName: canonicalName + domainValidationMethod: domainValidationMethod + } +} diff --git a/.azure/modules/web-appservice.bicep b/.azure/modules/web-appservice.bicep new file mode 100644 index 0000000..b77fd7f --- /dev/null +++ b/.azure/modules/web-appservice.bicep @@ -0,0 +1,98 @@ +@description('The name of the App Service Web App. Must be 1-60 characters, using only alphanumeric characters and hyphens.') +@minLength(1) +@maxLength(60) +param name string + +@description('The Azure region where the Web App will be deployed.') +param location string + +@description('Tags to apply to the Web App resource.') +param tags object = {} + +@description('The environment for the Web App. Allowed values: Development, QA, Staging, Production. Default is Development.') +@allowed([ + 'Development' + 'QA' + 'Staging' + 'Production' +]) +param environment string = 'Development' + +@description('The Application Insights instrumentation key for the Web App.') +@minLength(1) +param appiKey string + +@description('The Application Insights connection string for the Web App.') +@minLength(1) +param appiConnection string + +@description('The resource ID of the App Service Plan.') +@minLength(1) +param planId string + +@description('The kind of the Web App. Allowed values: api, app, app,linux, functionapp, functionapp,linux. Default is app.') +@allowed([ + 'api' + 'app' + 'app,linux' + 'functionapp' + 'functionapp,linux' +]) +param kind string = 'app' + +@description('The .NET version for the Web App. Allowed values: v4.8 (for .NET Framework), 6.0, 7.0, 8.0, 9.0, 10.0 (for .NET). Default is 10.0.') +@allowed([ + 'v4.8' + '6.0' + '7.0' + '8.0' + '9.0' + '10.0' +]) +param dotnetVersion string = '10.0' + +@description('Enable Always On for the App Service') +param alwaysOn bool = false + +@description('Enable WebSockets for the App Service') +param websockets bool = true + +resource webAppResource 'Microsoft.Web/sites@2023-12-01' = { + name: name + location: location + kind: kind + tags: empty(tags) ? null : tags + properties: { + serverFarmId: planId + httpsOnly: true + siteConfig: { + netFrameworkVersion: dotnetVersion + ftpsState: 'Disabled' + webSocketsEnabled: websockets + alwaysOn: alwaysOn + appSettings: [ + { + name: 'APPINSIGHTS_INSTRUMENTATIONKEY' + value: appiKey + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: appiConnection + } + { + name: 'ASPNETCORE_ENVIRONMENT' + value: environment + } + { + name: 'WEBSITE_RUN_FROM_PACKAGE' + value: '1' + } + ] + } + } + identity: { + type: 'SystemAssigned' + } +} + +output id string = webAppResource.id diff --git a/.azure/modules/work-loganalyticsworkspace.bicep b/.azure/modules/work-loganalyticsworkspace.bicep new file mode 100644 index 0000000..487b9fb --- /dev/null +++ b/.azure/modules/work-loganalyticsworkspace.bicep @@ -0,0 +1,33 @@ + +@description('The name of the Log Analytics workspace. Must be 4-63 characters, using only letters, numbers, and hyphens.') +@minLength(4) +@maxLength(63) +param name string + +@description('The Azure region where the Log Analytics workspace will be deployed.') +param location string + +@description('The SKU (pricing tier) for the Log Analytics workspace. Allowed values: Free, PerGB2018, CapacityReservation. Default is PerGB2018.') +@allowed([ + 'Free' + 'PerGB2018' + 'CapacityReservation' +]) +param sku string = 'PerGB2018' + +@description('Tags to apply to the Log Analytics workspace resource.') +param tags object = {} + +resource workResource 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: name + location: location + tags: empty(tags) ? null : tags + properties: { + sku: { + name: sku + } + retentionInDays: 30 + } +} + +output id string = workResource.id diff --git a/.azure/scripts/System.psm1 b/.azure/scripts/System.psm1 new file mode 100644 index 0000000..5768629 --- /dev/null +++ b/.azure/scripts/System.psm1 @@ -0,0 +1,1483 @@ +#----------------------------------------------------------------------- +# Add-Prefix [-String []] +# +# Example: .\Add-Prefix -String ello -Add H +# Result: Hello +#----------------------------------------------------------------------- +function Add-Prefix +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Add = $(throw '-Add is a required parameter.') + ) + Write-Verbose "Add-Prefix -String $String -Add $Add" + [string]$ReturnValue = $String + if (-not (Compare-IsFirst -String $String -BeginsWith $Add)) + { + $ReturnValue = ($Add + $ReturnValue) + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -String $String already has prefix of -Add $Add" + } + return $ReturnValue +} +export-modulemember -function Add-Prefix + +#----------------------------------------------------------------------- +# Add-Suffix [-String []] +# +# Example: .\Add-Suffix -String Hell -Add o +# Result: Hello +#----------------------------------------------------------------------- +function Add-Suffix +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Add = $(throw '-Add is a required parameter.') + ) + Write-Verbose "Add-Suffix -String $String -Add $Add" + [string]$ReturnValue = $String + if (-not (Compare-IsLast -String $String -EndsWith $Add)) + { + $ReturnValue = ($String + $Add) + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -String $String already has suffix of -Add $Add" + } + + return $ReturnValue +} +export-modulemember -function Add-Suffix + +#----------------------------------------------------------------------- +# Compare-IsFirst [-String []] +# +# Example: .\Compare-IsFirst -String Hell -EndsWith H +# Result: false +# Example: .\Compare-IsFirst -String Hello -Add H +# Result: Hello +#----------------------------------------------------------------------- +function Compare-IsFirst +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$BeginsWith = $(throw '-BeginsWith is a required parameter.') + ) + + Write-Verbose "Compare-IsFirst -String $String -EndsWith $EndsWith" + [Boolean]$ReturnValue = $false + if($BeginsWith.Length -lt $String.Length) + { + $StringBeginning = $String.SubString(0, $BeginsWith.Length).ToLower() + if ($StringBeginning.ToLower().Equals($BeginsWith.ToLower())) + { + $ReturnValue = $true + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + return $ReturnValue +} +export-modulemember -function Compare-IsFirst + +#----------------------------------------------------------------------- +# Compare-IsLast [-String []] +# +# Example: .\Compare-IsLast -String Hell -EndsWith H +# Result: false +# Example: .\Compare-IsLast -String Hello -Add H +# Result: Hello +#----------------------------------------------------------------------- +function Compare-IsLast +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$EndsWith = $(throw '-EndsWith is a required parameter.') + ) + Write-Verbose "Compare-IsLast -String $String -EndsWith $EndsWith" + [Boolean]$ReturnValue = $false + if($EndsWith.Length -lt $String.Length) + { + $StringEnding = $String.SubString(($String.Length - $EndsWith.Length), $EndsWith.Length) + if ($StringEnding.ToLower().Equals($EndsWith.ToLower())) + { + $ReturnValue = $true + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + + return $ReturnValue +} +export-modulemember -function Compare-IsLast + +#----------------------------------------------------------------------- +# Compress-Path [-Path []] [-File []] +# +# Example: .\Compress-Path \\source\path \\destination\path\file.zip +#----------------------------------------------------------------------- +function Compress-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$File=$(throw '-File is a required parameter.') + ) + Write-Verbose "Compress-Path -Path $Path -File $File" + New-Path -Path $Path + Remove-File $File + [Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") + [System.IO.Compression.ZipFile]::CreateFromDirectory($Path, $File) +} +export-modulemember -function Compress-Path + +#----------------------------------------------------------------------- +# Convert-PathSafe [-Path []] +# +# Example: .\Convert-PathSafe -Path \\source\path +#----------------------------------------------------------------------- +function Convert-PathSafe +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Convert-PathSafe -Path $Path" + $Path = $Path.Trim() + $ReturnValue = $Path + $Path = Set-Unc -Path $Path + if(Test-Path -Path $Path) + { + $ReturnValue = Convert-Path -Path $Path + if (-not ($ReturnValue)) + { + $ReturnValue = $Path + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path didnt convert." + } + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } + return $ReturnValue +} +export-modulemember -function Convert-PathSafe + +#----------------------------------------------------------------------- +# Copy-Backup [-Path []] [-Destination []] +# +# Example: .\Copy-Backup -Path \\source\path -Destination \\destination\path +#----------------------------------------------------------------------- +function Copy-Backup +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Destination = $(throw '-Destination is a required parameter.') + ) + Write-Verbose "Copy-Backup -Path $Path -Destination $Destination" + $Path = Remove-Suffix -String $Path -Remove "\" + New-Path -Path $Destination + [String]$BackupPath=[string]::Format("{0}\{1}", $Destination, (Get-Date).ToString("yyyy-MM-dd")) + if($Path) + { + if(-not (Test-Path -Path $BackupPath -PathType Container)){ + New-Path -Path $BackupPath + } + Copy-Recurse -Path $Path -Destination $BackupPath + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path to -Destination $BackupPath" + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Copy-Backup + +#----------------------------------------------------------------------- +# Copy-File [-Path []] [-Destination []] +# +# Example: .\Copy-File -Path \\source\path\File.name -Destination \\destination\path +#----------------------------------------------------------------------- +function Copy-File +{ + param ( + [Parameter(Mandatory = $True)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory = $True)] + [string]$Destination = $(throw '-Destination is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [bool]$Overwrite = $true + ) + Write-Verbose "Copy-File -Path $Path -Destination $Destination -Overwrite $Overwrite" + $Destination = Set-Unc -Path $Destination + if(Test-File -Path $Path) + { + New-Path -Path $Destination + $DestinationAbsolute = $Destination + if(Test-Folder -Path $Destination) + { + $DestinationAbsolute = Convert-PathSafe -Path $Destination + } + $DestinationPathFile = $DestinationAbsolute + $FolderArray = $Path.Split('\') + if($FolderArray.Count -gt 0) + { + $DestinationPathFile = Join-Path $DestinationAbsolute $FolderArray[$FolderArray.Count-1] + } + if((-not (Test-Path $DestinationPathFile -PathType Leaf)) -or ($Overwrite -eq $true)) + { + try{ + Copy-Item -Path $Path -Destination $DestinationAbsolute -Include $Include -Exclude $Exclude -Force + } + catch{ + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path to -Destination $DestinationAbsolute" + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Copy-File + +#----------------------------------------------------------------------- +# Copy-Recurse [-Source []] [-Destination []] +# [-Include [] [-Exclude []] +# +# Example: .\Copy-Recurse \\source\path \\destination\path +#----------------------------------------------------------------------- +function Copy-Recurse +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Source is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Destination = $(throw '-Destination is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 1000, + [bool]$Overwrite=$True, + [bool]$Clean = $False + ) + Write-Verbose "Copy-Recurse -Path $Path -Destination $Destination -Include $Include -Exclude $Exclude -First $ -Overwrite $Overwrite -Clean $Clean" + $Affected = 0 + $Path = Set-Unc -Path $Path + if (Test-Path $Path) + { + $PathAbsolute = Convert-PathSafe -Path $Path + # Optionally Clean + if($Clean -eq $True) { Remove-Path -Path $Destination } + New-Path -Path $Destination + $DestinationAbsolute = $Destination + if(Test-Path $Destination) { $DestinationAbsolute=Convert-PathSafe -Path $Destination } + $Items = Get-ChildItem -Path $PathAbsolute -Recurse -Include $Include -Exclude $Exclude | where { ! $_.PSIsContainer } + ForEach ($Item in $Items) { + $PathArray = $PathAbsolute.Split('\') + $Folder = $DestinationAbsolute + for ($count=1; $count -lt $PathArray.length-1; $count++) { + $Subfolder = $PathArray[$count] + $Folder = Join-Path $Folder $Subfolder + if (($Folder.Length > 0) -and (-not (Test-Path $Folder))) { + Write-Verbose "New-Item -ItemType directory -Force -Path $Folder" + New-Item -ItemType directory -Force -Path $Folder + } + } + $DirName = $Item.DirectoryName + $Position = $DirName.IndexOf($PathAbsolute) + $PathSegment = $DirName.SubString($Position + $PathAbsolute.Length) + $NewPath = Join-Path $DestinationAbsolute $PathSegment + Copy-File -Path $Item.FullName -Destination $NewPath -Overwrite $Overwrite + $Affected = $Affected + 1 + } + Write-Verbose "[Success] $Affected items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Copy-Recurse + +#----------------------------------------------------------------------- +# Expand-File [-Path []] [-File []] +# +# Example: .\Expand-File \\source\path\file.zip \\destination\path +#----------------------------------------------------------------------- +function Expand-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$File=$(throw '-File is a required parameter.') + ) + Write-Verbose "Expand-Zip -Path $Path -File $File" + New-Path -Path $Path + [Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") + [System.IO.Compression.ZipFile]::ExtractToDirectory($File, $Path) +} +export-modulemember -function Expand-File + +#----------------------------------------------------------------------- +# Find-File [-Path []] [-File []] +# +# Example: .\Find-File \\source\path\file.zip \\destination\path +#----------------------------------------------------------------------- +function Find-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$File=$(throw '-File is a required parameter.'), + [Int32]$First=1 + ) + Write-Verbose "Find-Zip -Path $Path -File $File" + Get-Childitem -Path $Path -Include $File -Recurse| select -First $First +} +export-modulemember -function Find-File + +#----------------------------------------------------------------------- +# Get-AssemblyStrongName +# +# Example: Get-AssemblyStrongName +#----------------------------------------------------------------------- +function Get-AssemblyStrongName($assemblyPath) +{ + [System.Reflection.AssemblyName]::GetAssemblyName($assemblyPath).FullName +} +export-modulemember -function Get-AssemblyStrongName + +#----------------------------------------------------------------------- +# Get-CurrentLine +# +# Example: Get-CurrentLine +#----------------------------------------------------------------------- +function Get-CurrentLine { + $MyInvocation.ScriptLineNumber +} +export-modulemember -function Get-CurrentLine + +#----------------------------------------------------------------------- +# Get-CurrentFile +# +# Example: Get-CurrentFile +#----------------------------------------------------------------------- +function Get-CurrentFile { + $MyInvocation.ScriptName +} +export-modulemember -function Get-CurrentFile + +#----------------------------------------------------------------------- +# Get-FilesByString +# +# Example: Get-FilesByString +#----------------------------------------------------------------------- +function Get-FilesByString { + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [string]$String = $(throw '-String is a required parameter.'), + [string[]]$Include = ("*.*"), + [string[]]$Exclude = "" + ) + Write-Host "Get-FilesByString -Path $Path -String $String -Include $Include -Exclude $Exclude" + $Path = Set-Unc -Path $Path + + $ReturnData = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse | Select-String -pattern $String | group path | select name + + return $ReturnData +} +export-modulemember -function Get-FilesByString + +#----------------------------------------------------------------------- +# Get-SystemFolders +# +# Example: Get-SystemFolder +#----------------------------------------------------------------------- +function Get-SystemFolders +{ + param ( + ) + Write-Verbose "Get-SystemFolders" + $SpecialFolders = @{} + $names = [Environment+SpecialFolder]::GetNames([Environment+SpecialFolder]) + foreach($name in $names) + { + if($path = [Environment]::GetFolderPath($name)){ + $SpecialFolders[$name] = $path + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + } + return $SpecialFolders +} +export-modulemember -function Get-SystemFolders + +#----------------------------------------------------------------------- +# Get-SystemFolder [-Name []] +# Keys: Desktop,Programs,MyDocuments,Personal,Favorites,Startup,Recent,SendTo,StartMenu,MyMusic,MyVideos,DesktopDirectory,NetworkShortcuts,Fonts +# Templates,CommonStartMenu,CommonPrograms,CommonStartup,CommonDesktopDirectory,ApplicationData,PrinterShortcuts,LocalApplicationData,InternetCache +# Cookies,History,CommonApplicationData,Windows,System,ProgramFiles,MyPictures,UserProfile,SystemX86,ProgramFilesX86 +# CommonProgramFiles,CommonProgramFilesX86,CommonTemplates,CommonDocuments,CommonAdminTools,AdminTools,CommonMusic,CommonPictures,CommonVideos,ResourcesCDBurning +# Example: Get-SystemFolder -Name 'UserProfile' +#----------------------------------------------------------------------- +function Get-SystemFolder +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [String]$Name=$(throw '-Folder is a required parameter.') + ) + Write-Verbose "Get-SystemFolder -Folder $Folder" + if($path = [Environment]::GetFolderPath($name)){ + $Folder = $path + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + return $Folder +} +export-modulemember -function Get-SystemFolder + +#----------------------------------------------------------------------- +# Move-Path [-Path []] [-Destination []] +# [-Exclude []] +# +# Example: .\Move-Path -Path \\source\path +#----------------------------------------------------------------------- +function Move-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Destination = $(throw '-Destination is a required parameter.'), + [string]$Exclude = "" + ) + Write-Verbose "Move-Path -Path $Path -Destination $Destination" + $Path = Remove-Suffix -String $Path -Remove "\" + if (test-folder -Path $Path) + { + Remove-Path -Destination $Destination + New-Path -Destination $Destination + Copy-Recurse -Path $Path -Destination $Destination -Exclude $Exclude + Remove-Path -Destination $Path + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path moved to -Destination $Destination" + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Move-Path + +#----------------------------------------------------------------------- +# New-Path [-Path []] +# +# Example: .\New-Path \\source\path +#----------------------------------------------------------------------- +function New-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [bool]$Clean=$false + ) + Write-Verbose "New-Path -Path $Path" + [String]$Folder = "" + $Path = Remove-Suffix -String $Path -Remove "\" + if ($Clean) {Remove-Path -Path $Path} + if (-not (test-path $Path)) { + if (Test-Unc $Path) + { + $PathArray = $Path.Split('\') + foreach($item in $PathArray) + { + if($item.Length -gt 0) + { + if($Folder.Length -lt 1) + { + $Folder = "\\$item" + } + else + { + $Folder = "$Folder\$item" + if (-not (Test-Path $Folder)) { + New-Item -ItemType directory -Path $Folder -Force + } + } + } + } + } + else + { + New-Item -ItemType directory -Path $Path -Force + } + } +} +export-modulemember -function New-Path + +#----------------------------------------------------------------------- +# Redo-Path [-Path []] [-Destination []] +# [-Exclude []] +# +# Example: .\Redo-Path -Path \\source\path +#----------------------------------------------------------------------- +function Redo-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Destination = $(throw '-Destination is a required parameter.'), + [string]$Exclude = "" + ) + Write-Verbose "Redo-Path -Path $Path -Destination $Destination" + $Path = Remove-Suffix -String $Path -Remove "\" + Remove-Path -Destination $Path + New-Path -Destination $Path + Copy-Recurse -Path $Path -Destination $Destination -Exclude $Exclude + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path to -Destination $Destination" +} +export-modulemember -function Redo-Path + +#----------------------------------------------------------------------- +# Remove-File [-File []] +# +# Example: .\Remove-File \\source\path\file.txt +#----------------------------------------------------------------------- +function Remove-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.') + ) + Write-Verbose "Remove-File -Path $Path" + if (Test-File -Path $Path) { + Remove-Item -Path $Path -Force + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path removed." + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Remove-File + +#----------------------------------------------------------------------- +# Remove-Path [-Path []] +# [-Include [] [-Exclude []] +# +# Example: .\Remove-Path -Path \\source\path +#----------------------------------------------------------------------- +function Remove-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int16]$Retention = 1, + [Int32]$First = 1000 + ) + Write-Verbose "Remove-Path -Path $Path -Include $Include -Exclude $Exclude -Retention $Retention -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (test-folder -Path $Path) { + $ErrorActionPreferenceBackup = $ErrorActionPreference + $ErrorActionPreference = 'SilentlyContinue' + Get-ChildItem -Path $Path -Include $Include -Exclude $Exclude -Recurse | Where-Object {($_.PSIsContainer) -and ($_.lastwritetime -le (get-date).addDays(($Retention*-1)))} | select -First $First | Remove-Item -Force -Recurse + Remove-Item $Path -Recurse -Force + $ErrorActionPreference = $ErrorActionPreferenceBackup + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path removed." + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Remove-Path + +#----------------------------------------------------------------------- +# Remove-Subfolders [-Path []] +# [-Include [] [-Exclude []] +# +# Example usage: +# Remove-Subfolders -Path "C:\YourProjectPath" -Subfolder "bin" +# Remove-Subfolders -Path "C:\YourProjectPath" -Subfolder "obj" +#----------------------------------------------------------------------- +function Remove-Subfolders { + param ( + [Parameter(Mandatory=$true)] + [string]$Path, + [Parameter(Mandatory=$true)] + [string]$Subfolder + ) + + Write-Verbose "Removing subfolders -Path $Path -Subfolder $Subfolder" + + if (Test-Path -Path $Path) { + $Folders = Get-ChildItem -Path $Path -Recurse -Directory -Filter $Subfolder + foreach ($Folder in $Folders) { + Remove-Item -Path $Folder.FullName -Recurse -Force + Write-Verbose "Removed $($Folder.FullName)" + } + Write-Verbose "[Success] Removed $($Folders.Count) '$Subfolder' folders." + } else { + Write-Verbose "[Warning] Path $Path does not exist." + } +} +export-modulemember -function Remove-Subfolders + +#----------------------------------------------------------------------- +# Remove-Recurse [-Source []] [-Destination []] +# [-Include [] [-Exclude []] +# +# Example: .\Remove-Recurse \\source\path \\destination\path +#----------------------------------------------------------------------- +function Remove-Recurse +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Source is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 1000 + ) + Write-Verbose "Remove-Recurse -Path $Path -Include $Include -Exclude $Exclude" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $PathAbsolute = Convert-PathSafe -Path $Path + $Items = Get-ChildItem -Path $PathAbsolute -Recurse -Include $Include -Exclude $Exclude | where { ! $_.PSIsContainer } + $Affected = 0 + ForEach ($Item in $Items) { + Remove-File -Path $Item + $Affected = $Affected + 1 + } + Write-Verbose "[Success] $Affected items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path." + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Remove-Recurse + +#----------------------------------------------------------------------- +# Remove-Prefix [-String []] +# +# Example: .\Remove-Prefix -String Hell -Remove o +# Result: Hello +#----------------------------------------------------------------------- +function Remove-Prefix +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Remove = $(throw '-Remove is a required parameter.') + ) + Write-Verbose "Remove-Prefix -String $String -Remove $Remove" + [string]$ReturnValue = $String + if (Compare-IsFirst -String $String -BeginsWith $Remove) + { + $ReturnValue = $String.Substring($Remove.Length, $String.Length - $Remove.Length) + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -String $String already has prefix of -Remove $Remove" + } + + return $ReturnValue +} +export-modulemember -function Remove-Prefix + +#----------------------------------------------------------------------- +# Remove-Suffix [-String []] +# +# Example: .\Remove-Suffix -String Hell -Remove o +# Result: Hello +#----------------------------------------------------------------------- +function Remove-Suffix +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Remove = $(throw '-Remove is a required parameter.') + ) + Write-Verbose "Remove-Suffix -String $String -Remove $Remove" + [string]$ReturnValue = $String + if($String) + { + if (Compare-IsLast -String $String -EndsWith $Remove) + { + $ReturnValue = $ReturnValue.Substring(0, $String.Length - $Remove.Length) + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -String $String already has suffix of -Remove $Remove" + } + + return $ReturnValue +} +export-modulemember -function Remove-Suffix + +#----------------------------------------------------------------------- +# Remove-Element [-Path []] +# +# Example: .\Remove-Element -Value "" +# -XPath "//msb:None/msb:Generator" +# -Namespace @{msb = "http://schemas.microsoft.com/developer/msbuild/2003"} +# +# Called: $XMLValue = [xml](Get-Content $path) +# $Namespace = @{msb = 'http://schemas.microsoft.com/developer/msbuild/2003'} +# Remove-Element $XMLValue -XPath '//msb:None/msb:Generator' -Namespace $Namespace +# $proj.Save($path) +#----------------------------------------------------------------------- +function Remove-Element +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [xml]$Value=$(throw '-Value is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [String]$XPath=$(throw '-Value is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [String]$Namespace=$(throw '-Value is a required parameter.') + ) + Write-Verbose ".\Remove-Element -Value $Value -XPath $XPath -Namespace $Namespace -SingleNode" + $nodes = @(Select-Xml $XPath $Value -Namespace $Namespace | Foreach {$_.Node}) + if (!$nodes) { Write-Verbose "RemoveElement: XPath $XPath not found" } + if ($singleNode -and ($nodes.Count -gt 1)) { + throw "XPath $XPath found multiple nodes" + } + $Count = 0 + foreach ($node in $nodes) + { + $parentNode = $node.ParentNode + [void]$parentNode.RemoveChild($node) + $Count = $Count + 1 + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." +} +export-modulemember -function Remove-Element + +#----------------------------------------------------------------------- +# Remove-ContentsByTagContains [-Path []] +# [-Open [] [-Close []] +# +# Example: .\Remove-ContentsByTagContains \\source\path \\destination\path +# GlobalSection(TeamFoundationVersionControl) = preSolution +# EndGlobalSection +#----------------------------------------------------------------------- +function Remove-ContentsByTag +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Open = $(throw '-Open is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Close = $(throw '-Close is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Verbose "Remove-ContentsByTag -Path $Path -Open $Open -Close $Close -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $Open = $Open.Trim() + $Close = $Close.Trim() + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$OpenIndex = -1 + [Int32]$CloseIndex = -1 + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If(($OpenIndex -eq -1) -and ($CurrentLine -eq $Open)) + { + $OpenIndex = $Count + } + ElseIf(($OpenIndex -gt -1) -and ($CurrentLine -eq $Close)) + { + $CloseIndex = $Count + Break + } + } + # Evaluate search + If(($OpenIndex -gt -1) -and ($OpenIndex -lt $CloseIndex)) + { + # Match Found Remove block. + $NewContent = ($Content | Select -First $OpenIndex) + ($Content | select -Last ($Content.Length - $CloseIndex - 1)) + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Remove-ContentsByTag + +#----------------------------------------------------------------------- +# Remove-ContentsByTagContains [-Path []] +# [-Open [] [-Close []] +# +# Example: .\Remove-ContentsByTagContains \\source\path \\destination\path +# GlobalSection(TeamFoundationVersionControl) = preSolution +# EndGlobalSection +#----------------------------------------------------------------------- +function Remove-ContentsByTagContains +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Open = $(throw '-Open is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Close = $(throw '-Close is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Contains = $(throw '-Contains is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Verbose "Remove-ContentsByTagContains -Path $Path -Open $Open -Close $Close -Contains $Contains -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $Open = $Open.Trim() + $Close = $Close.Trim() + $Contains = $Contains.Trim() + + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$OpenIndex = -1 + [Int32]$ContainsIndex = -1 + [Int32]$CloseIndex = -1 + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If ($CurrentLine -like "*$Open*") + { + If($OpenIndex -gt -1) + { + # Fail: Block did not contain -Content and/or -Open was found before -Close. Reset for next open tag match. + $ContainsIndex = -1 + $CloseIndex = -1 + } + $OpenIndex = $Count + }ElseIf($OpenIndex -gt -1) + { + If($CurrentLine -like "*$Contains*") + { + $ContainsIndex = $Count + }ElseIf(($ContainsIndex -gt -1) -and ($CurrentLine -like "*$Close*")) + { + # Success, block starts with -Open, ends with -Close and includes -Contains + $CloseIndex = $Count + Break + } + } + } + # Any matches? + If(($OpenIndex -gt -1) -and ($ContainsIndex -gt $OpenIndex) -and ($CloseIndex -gt $ContainsIndex)) + { + If($CloseIndex -eq ($OpenIndex + 2)) + { + # Match Found with single element. Remove Block. + $NewContent = ($Content | Select -First $OpenIndex) + ($Content | select -Last ($Content.Length - $CloseIndex - 1)) + } + Else + { + # Match Found with multiple elements. Remove Line Only. + $NewContent = ($Content | Select -First $ContainsIndex) + ($Content | select -Last ($Content.Length - $ContainsIndex -1)) + } + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Remove-ContentsByTagContains + +#----------------------------------------------------------------------- +# Rename-File [-File []] +# +# Example: .\Rename-File -Path ($StagingZipPath + 'root.vstbak') -NewName root.vstemplate -Force +#----------------------------------------------------------------------- +function Rename-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.'), + [string]$NewName=$(throw '-NewName is a required parameter.') + ) + Write-Verbose "Rename-File -Path $Path -NewName $NewName" + if (Test-File -Path $Path) { + Rename-Item -Path $Path -NewName $NewName -Force + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path removed." + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Rename-File + +#----------------------------------------------------------------------- +# Set-ReadOnly [-Path []] [-ReadOnly []] +# +# Example: .\Set-ReadOnly -Path \\source\path\File.name -ReadOnly $False +#----------------------------------------------------------------------- +function Set-ReadOnly +{ + param ( + [Parameter(Mandatory = $True)] + [string]$Path = $(throw '-Path is a required parameter.'), + [bool]$ReadOnly = $True, + [string]$ErrorPreference = 'SilentlyContinue' + ) + Write-Verbose "Set-ReadOnly -Path $Path -ReadOnly $ReadOnly -ErrorPreference $ErrorPreference" + $Path = Remove-Suffix -String $Path -Remove "\" + if(test-path $Path) + { + $PathAbsolute = Convert-PathSafe -Path $Path + if (Test-Path $PathAbsolute -PathType Leaf) + { + $ErrorActionPreferenceBackup = $ErrorActionPreference + $ErrorActionPreference = $ErrorPreference + Set-ItemProperty $PathAbsolute -name IsReadOnly -value $ReadOnly -Force + $ErrorActionPreference = $ErrorActionPreferenceBackup + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path set." + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Set-ReadOnly + +#----------------------------------------------------------------------- +# Set-SystemFolderDrives +# +# Example: Set-SystemFolderDrives +#----------------------------------------------------------------------- +function Set-SystemFolderDrives +{ + param ( + ) + Write-Verbose "Set-SystemFolderDrives" + + $SpecialFolders = @{} + $names = [Environment+SpecialFolder]::GetNames([Environment+SpecialFolder]) + foreach($name in $names) + { + if($path = [Environment]::GetFolderPath($name)){ + $SpecialFolders[$name] = $path + New-PSDrive -Name $name -PSProvider FileSystem -Root $path + } + } + # #TBD: Find the 10 Largest Files in the Documents Folder + # gci Personal: -Recurse -Force -ea SilentlyContinue | + # Sort-Object -Property Length -Descending | + # Select-Object -First 10 | + # Format-Table -AutoSize -Wrap -Property ` + # Length,LastWriteTime,FullName + return $SpecialFolders +} +export-modulemember -function Set-SystemFolderDrives + +#----------------------------------------------------------------------- +# Test-File [-Path []] +# +# Example: .\Test-File -Path \\source\path +#----------------------------------------------------------------------- +function Test-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Test-File -Path $Path" + [bool]$ReturnValue = $false + if(Test-Path -Path $Path -PathType Leaf) + { + $ReturnValue = $true + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist or is not a File." + } + return $ReturnValue +} +export-modulemember -function Test-File + +#----------------------------------------------------------------------- +# Test-Folder [-Path []] +# +# +# Example: .\Test-Folder -Path \\source\path +#----------------------------------------------------------------------- +function Test-Folder +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Test-Folder -Path $Path" + [bool]$ReturnValue = $false + if(test-path -Path $Path -PathType Container) + { + $ReturnValue = $true + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist or is not a Folder." + } + return $ReturnValue +} +export-modulemember -function Test-Folder + +#----------------------------------------------------------------------- +# Test-PathEmpty [-Path []] +# +# Example: .\Test-PathEmpty -Path \\source\path +#----------------------------------------------------------------------- +function Test-PathEmpty +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Test-PathEmpty -Path $Path" + [bool]$ReturnValue = $false + if((Get-ChildItem $Path -force | Select-Object -First 1 | Measure-Object).Count -eq 0) + { + $ReturnValue = $true + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } + return $ReturnValue +} +export-modulemember -function Test-PathEmpty + +#----------------------------------------------------------------------- +# Set-Unc [-Path []] +# +# Example: .\Set-Unc -Path \\source\path +#----------------------------------------------------------------------- +function Set-Unc +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Set-Unc -Path $Path" + $Path = $Path.Trim() + $Path = Remove-Suffix -String $Path -Remove '\' + if(-not ($Path.Contains(':\') -or $Path.Contains('.\') -or (Compare-IsFirst -String $Path -BeginsWith '\'))) + { + $ReturnValue = Add-Prefix -String $Path -Add '\\' + } + else + { + $ReturnValue = $Path + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path already a UNC, drive letter, absolute or relative path." + } + return $ReturnValue +} +export-modulemember -function Set-Unc + +#----------------------------------------------------------------------- +# Test-Unc [-Path []] +# +# +# Example: .\Test-Unc -Path \\source\path +#----------------------------------------------------------------------- +function Test-Unc +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Test-Unc -Path $Path" + [bool]$ReturnValue = $false + if(($Path.Contains('\\')) -and (-not ($Path.Contains(':\')))) + { + $ReturnValue = $true + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + return $ReturnValue +} +export-modulemember -function Test-Unc + +#----------------------------------------------------------------------- +# Update-LineByContains [-Path []] +# [-Contains [] [-Close []] +# +# Example: .\Update-LineByContains -Path \\source\path -Include AssemblyInfo.cs -Contains 'AssemblyVersion(' -Line '[assembly: AssemblyVersion("5.20.07")]' +#----------------------------------------------------------------------- +function Update-LineByContains +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Contains = $(throw '-Contains is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Line = $(throw '-Line is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Host "Update-LineByContains -Path $Path -Contains $Contains -Line $Line -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $Contains = $Contains.Trim() + $Count = 0 + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$ContainsIndex = -1 + $Affected = 0 + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If(($ContainsIndex -eq -1) -and ($CurrentLine -eq $Contains)) + { + $ContainsIndex = $Count + Break + } + } + # Evaluate search + If($ContainsIndex -gt -1) + { + # Select before line, add -Line, select after line + $NewContent = (($Content | Select -First $ContainsIndex) + ($Line + [Environment]::NewLine) + ($Content | select -Last ($Content.Length - $ContainsIndex -1))) + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + $Affected = $Count + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-LineByContains + +#----------------------------------------------------------------------- +# Update-ContentsByTag [-Path []] +# [-Open [] [-Close []] +# +# Example: .\Update-ContentsByTag -Path $Path -Include *.sln -Open "GlobalSection(TeamFoundationVersionControl) = preSolution" -Close "EndGlobalSection" +#----------------------------------------------------------------------- +function Update-ContentsByTag +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Open = $(throw '-Open is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Close = $(throw '-Close is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Value = $(throw '-Value is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Host "Update-ContentsByTag -Path $Path -Open $Open -Close $Close -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + [String]$PaddingLeft = ' ' + if (Test-Path $Path) + { + $Open = $Open.Trim() + $Close = $Close.Trim() + $Affected = 0 + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$OpenIndex = -1 + [Int32]$CloseIndex = -1 + [String]$NewValue = "" + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If(($OpenIndex -eq -1) -and ($CurrentLine -like "*$Open*")) + { + $OpenIndex = $Count + } + If(($OpenIndex -gt -1) -and ($CurrentLine -like "*$Close*")) + { + $CloseIndex = $Count + Break + } + } + # Evaluate search + If(($OpenIndex -gt -1) -and ($OpenIndex -le $CloseIndex)) + { + if($OpenIndex -eq $CloseIndex) + { + # Open/Close on same line, rebuild the line with new contents + $NewValue = ($Open + $Value + $Close) + } + else + { + $NewValue = $Value + } + # Update content + $NewContent = ($Content | Select -First ($OpenIndex)) + ($PaddingLeft + $NewValue) + ($Content | select -Last ($Content.Length - $CloseIndex - 1)) + $Affected = 1 + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + } + Write-Verbose "[Success] $Affected items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-ContentsByTag + +#----------------------------------------------------------------------- +# Update-Text [-Path []] +# [-Include [] [-Exclude []] +# +# Example: .\Update-Text -Path \\source\path -Include *.cs -Old "Use gotos" -New "Point at people who use gotos" +#----------------------------------------------------------------------- +function Update-Text +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Old = $(throw '-Old is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$New = $(throw '-New is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string[]]$Include = $(throw '-Include is a required parameter.'), + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Host "Update-Text -Path $Path -Old $Old -New $New -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + $Count = 0 + if (Test-Path $Path) + { + $ConfigFiles=Get-Childitem $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + foreach ($Item in $ConfigFiles) + { + Set-ReadOnly -Path $Item.PSPath -ReadOnly $false + (Get-Content $Item.PSPath) | + Foreach-Object {$_.Replace($Old, $New) + } | + Set-Content $Item.PSPath -force + $Count = $Count + 1 + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-Text + +#----------------------------------------------------------------------- +# Update-TextByContains [-Path []] +# [-Contains [] [-Close []] +# +# Example: .\Update-TextByContains -Path \\source\path -Include AssemblyInfo.cs -Contains 'AssemblyVersion(' -Line '[assembly: AssemblyVersion("5.20.07")]' +#----------------------------------------------------------------------- +function Update-TextByContains +{ + param ( + [string]$Path = $(throw '-Path is a required parameter.'), + [string]$Contains = $(throw '-Contains is a required parameter.'), + [string]$Old = $(throw '-Old is a required parameter.'), + [string]$New = $(throw '-New is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Host "Update-TextByContains -Path $Path -Contains $Contains -Old $Old -New $New -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $Contains = $Contains.Trim() + $Count = 0 + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$FoundIndex = -1 + [String]$FoundLine = '' + $Affected = 0 + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If(($FoundIndex -eq -1) -and ($CurrentLine.ToLowerInvariant().Contains($Contains.ToLowerInvariant()))) + { + $FoundIndex = $Count + $FoundLine = $CurrentLine + Break + } + } + # Evaluate search + If($FoundIndex -gt -1) + { + # Replace text inside of line + $NewLine = $FoundLine.Replace($Old, $New) + # Select before line, add $NewLine, select after line + $NewContent = (($Content | Select -First $FoundIndex) + ($NewLine + [Environment]::NewLine) + ($Content | select -Last ($Content.Length - $FoundIndex -1))) + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + $Affected = $Count + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-TextByContains + +#----------------------------------------------------------------------- +# Update-TextByTable [-Path []] [-Replace []] +# [-Include [] [-Exclude []] +# +# Example: .\Update-TextByTable -Path \\source\path -Include *.cs +# -Replace @{'Old1' = 'New1' +# 'Old2' = 'New2' +# 'Old3' = 'New3'} +#----------------------------------------------------------------------- +function Update-TextByTable +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [hashtable]$Replace = $(throw '-Replace is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string[]]$Include = $(throw '-Include is a required parameter.'), + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Host "Update-Text -Path $Path -Old $Old -New $New -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + Write-Verbose "Get-Childitem $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First" + $ConfigFiles=Get-Childitem $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + Write-Verbose "ConfigFiles: $ConfigFiles" + $Count = 0 + foreach ($Item in $ConfigFiles) + { + Write-Verbose "Get-Content $Item.PSPath" + $fileLines = Get-Content $Item.PSPath + Write-Verbose "fileLines: $fileLines" + if($fileLines) + { + foreach($replaceItem in $Replace.GetEnumerator()) { + Write-Verbose "$fileLines.Replace($replaceItem.Key, $replaceItem.Value)" + $fileLines = $fileLines.Replace($replaceItem.Key, $replaceItem.Value) + } + Write-Verbose "Set-Content -Path $Item.PSPath -Value $fileLines -force" + Set-Content -Path $Item.PSPath -Value $fileLines -force + } + $Count = $Count + 1 + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-TextByTable \ No newline at end of file diff --git a/.azure/scripts/entra/New-EntraAppRegistrationSecret.ps1 b/.azure/scripts/entra/New-EntraAppRegistrationSecret.ps1 new file mode 100644 index 0000000..70abc1b --- /dev/null +++ b/.azure/scripts/entra/New-EntraAppRegistrationSecret.ps1 @@ -0,0 +1,97 @@ + +# ============================================================================ +# Script Name: New-EntraAppRegistrationSecret.ps1 +# Description: Creates a new client secret for an existing Entra External ID App Registration and sets it in dotnet user-secrets for a project. +# ----------------------------------------------------------------------------- +# Example CLI Usage: +# pwsh -File ./New-EntraAppRegistrationSecret.ps1 -TenantId "" -AppRegistrationName "" -DotnetProjectPath "" +# ----------------------------------------------------------------------------- +# Notes: +# - Requires PowerShell modules: Az.Accounts, Microsoft.Graph.Applications +# - Ensure you are authenticated: Connect-AzAccount and Connect-MgGraph +# ============================================================================ +param( + [string]$TenantId, + [string]$AppRegistrationName, + [string]$DotnetProjectPath, + [int]$SecretMonths = 24, + [string]$DotNetVersion = "10" +) + +# Step 1: Install prerequisites (dotnet sdk, modules) +Write-Host "Checking prerequisites..." + +# Check and install .NET SDK +$dotnetInstalled = & dotnet --list-sdks | Select-String "^$DotNetVersion\." +if (-not $dotnetInstalled) { + Write-Host ".NET SDK $DotNetVersion not found. Installing via winget..." + winget install --id Microsoft.DotNet.SDK.$DotNetVersion -e --silent +} else { + Write-Host ".NET SDK $DotNetVersion is already installed." +} + +# Check and install PowerShell modules +$modules = @("Az.Accounts", "Az.Resources", "Microsoft.Graph.Applications") +foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "Installing PowerShell module: $module" + Install-Module $module -Scope CurrentUser -Force + } else { + Write-Host "PowerShell module $module is already installed." + } +} + +# Step 2: Login to Azure and set EEID tenant +Write-Host "Checking Azure authentication..." +$azContext = Get-AzContext -ErrorAction SilentlyContinue +if ($azContext -and $azContext.Tenant.Id -eq $TenantId) { + Write-Host "Already authenticated to Azure tenant $TenantId." +} else { + Write-Host "Logging into Azure..." + Connect-AzAccount -Tenant $TenantId | Out-Null + Write-Host "Logged in to Azure tenant $TenantId." +} + +# Check for Microsoft Graph authentication +$mgContext = $null +try { + $mgContext = Get-MgContext -ErrorAction Stop +} catch {} +if (-not $mgContext -or $mgContext.TenantId -ne $TenantId -or -not $mgContext.Account) { + Write-Host "Connecting to Microsoft Graph..." + Connect-MgGraph -TenantId $TenantId -Scopes "Application.ReadWrite.All","Directory.ReadWrite.All" | Out-Null + $mgContext = Get-MgContext -ErrorAction Stop +} + +# Find the app registration +$app = Get-MgApplication -Filter "displayName eq '$AppRegistrationName'" -ErrorAction SilentlyContinue +if (-not $app) { + Write-Error "FATAL: App registration '$AppRegistrationName' not found. Cannot create secret." + exit 1 +} + +# Create new client secret + +# Create password credential object for secret expiration +$endDate = (Get-Date).AddMonths($SecretMonths) +$passwordCredential = @{ EndDateTime = $endDate } +$secretObj = Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential $passwordCredential +if (-not $secretObj -or -not $secretObj.SecretText) { + Write-Error "FATAL: Failed to create client secret." + exit 1 +} +$secret = $secretObj.SecretText +Write-Host "Created new client secret for app registration '$AppRegistrationName'." + +# Set secret in dotnet user-secrets if project path exists +if (Test-Path $DotnetProjectPath) { + Write-Host "Setting EntraExternalId:ClientSecret in dotnet user-secrets for $DotnetProjectPath" + Push-Location $DotnetProjectPath + dotnet user-secrets init + dotnet user-secrets set "EntraExternalId:ClientSecret" $secret + Pop-Location + Write-Host "Secret set in dotnet user-secrets." +} else { + Write-Warning "*** CRITICAL: Project path '$DotnetProjectPath' not found. Skipping dotnet user-secrets. Secret was created in Entra ID. ***" +} + diff --git a/.azure/scripts/entra/New-EntraAppRegistrations.ps1 b/.azure/scripts/entra/New-EntraAppRegistrations.ps1 new file mode 100644 index 0000000..d166927 --- /dev/null +++ b/.azure/scripts/entra/New-EntraAppRegistrations.ps1 @@ -0,0 +1,446 @@ +# ============================================================================ +# Script Name: New-EntraAppRegistrations.ps1 +# Description: Creates new Entra External ID App Registrations. +# ----------------------------------------------------------------------------- +# Example CLI Usage: +# pwsh -File ./New-EntraAppRegistrations.ps1 ` +# -EntraInstanceUrl "https://your-tenant-name.ciamlogin.com" ` +# -TenantId "" ` +# -WebAppRegistrationName "myproduct-web-dev-001" ` +# -ApiAppRegistrationName "myproduct-api-dev-001" ` +# -WebProjectPath "../../src/Presentation.Blazor" ` +# -ApiProjectPath "../../src/Presentation.WebApi" +# ----------------------------------------------------------------------------- +# Notes: +# - Requires Azure PowerShell modules (Az.Accounts, Az.Resources, etc.) +# - Ensure you are authenticated: Connect-AzAccount +# ============================================================================ +param( + [string]$EntraInstanceUrl, + [string]$TenantId, + [string]$WebAppRegistrationName, + [string]$ApiAppRegistrationName, + [string]$WebProjectPath, + [string]$ApiProjectPath, + [string]$DotNetVersion = "10", + [string]$WebRedirectUri = "https://localhost:7175/signin-oidc", + [string]$WebLogoutUri = "https://localhost:7175/signout-callback-oidc" +) + +function New-ApiRegistration { + param( + [string]$ApiAppRegistrationName, + [string]$TenantId + ) + Write-Host "Checking for API app registration: $ApiAppRegistrationName..." + $apiApp = Get-MgApplication -Filter "displayName eq '$ApiAppRegistrationName'" -ErrorAction SilentlyContinue + $created = $false + if (-not $apiApp) { + Write-Host "API app registration not found. Creating..." + try { + $apiApp = New-MgApplication -DisplayName $ApiAppRegistrationName -SignInAudience AzureADMyOrg + $created = $true + } + catch { + Write-Error "FATAL: Failed to create API app registration. $_.Exception.Message" + exit 1 + } + if (-not $apiApp -or -not $apiApp.AppId) { + Write-Error "FATAL: API app registration was not created. Exiting script." + exit 1 + } + $apiAppId = $apiApp.AppId + Write-Host "Created API app registration with appId: $apiAppId" + $identifierUri = "api://$apiAppId" + Update-MgApplication -ApplicationId $apiApp.Id -IdentifierUris @($identifierUri) + Write-Host "Set IdentifierUri to $identifierUri" + $customScopes = @( + @{ Id = [guid]::NewGuid(); AdminConsentDisplayName = "Read assets"; AdminConsentDescription = "Allows the app to view asset data."; UserConsentDisplayName = "Read your assets"; UserConsentDescription = "Allows the app to view your assets."; IsEnabled = $true; Type = "User"; Value = "assets.read" }, + @{ Id = [guid]::NewGuid(); AdminConsentDisplayName = "Edit assets"; AdminConsentDescription = "Allows the app to create or update asset data."; UserConsentDisplayName = "Edit your assets"; UserConsentDescription = "Allows the app to create or update your assets."; IsEnabled = $true; Type = "User"; Value = "assets.write" }, + @{ Id = [guid]::NewGuid(); AdminConsentDisplayName = "Delete assets"; AdminConsentDescription = "Allows the app to delete asset data."; UserConsentDisplayName = "Delete your assets"; UserConsentDescription = "Allows the app to delete your assets."; IsEnabled = $true; Type = "User"; Value = "assets.delete" } + ) + Update-MgApplication -ApplicationId $apiApp.Id -Api @{ OAuth2PermissionScopes = $customScopes } + Write-Host "Added custom OAuth2 permission scopes to API app registration." + $appRoles = @( + @{ Id = [guid]::NewGuid(); AllowedMemberTypes = @("User"); Description = "Can view assets only."; DisplayName = "Asset Viewer"; IsEnabled = $true; Origin = "Application"; Value = "AssetViewer" }, + @{ Id = [guid]::NewGuid(); AllowedMemberTypes = @("User"); Description = "Can view and edit assets."; DisplayName = "Asset Editor"; IsEnabled = $true; Origin = "Application"; Value = "AssetEditor" }, + @{ Id = [guid]::NewGuid(); AllowedMemberTypes = @("User"); Description = "Can view, edit, and delete assets."; DisplayName = "Asset Admin"; IsEnabled = $true; Origin = "Application"; Value = "AssetAdmin" } + ) + Update-MgApplication -ApplicationId $apiApp.Id -AppRoles $appRoles + Write-Host "Added app roles to API app registration." + $apiApp = Wait-ForApplicationPropagation -AppId $apiApp.AppId -ObjectId $apiApp.Id + } + else { + Write-Host "API app registration $ApiAppRegistrationName already exists." + } + + Write-Host "Adding Microsoft Graph User.Read permission to API app registration..." + $msGraphSp = Get-MgServicePrincipal -Filter "displayName eq 'Microsoft Graph'" -ErrorAction Stop + if (-not $msGraphSp) { + Write-Error "FATAL: Microsoft Graph service principal not found. Exiting script." + exit 1 + } + $userReadPerm = $msGraphSp.Oauth2PermissionScopes | Where-Object { $_.Value -eq "User.Read" } + if (-not $userReadPerm) { + Write-Error "FATAL: Microsoft Graph User.Read permission not found. Exiting script." + exit 1 + } + $apiAppReqPerms = @{ + ResourceAppId = $msGraphSp.AppId + ResourceAccess = @(@{ Id = $userReadPerm.Id; Type = "Scope" }) + } + Update-MgApplication -ApplicationId $apiApp.Id -RequiredResourceAccess @($apiAppReqPerms) + Write-Host "Added Microsoft Graph User.Read delegated permission to API app registration." + + $apiApp = Wait-ForApplicationPropagation -AppId $apiApp.AppId + $apiSp = Get-MgServicePrincipal -All | Where-Object { $_.AppId -eq $apiApp.AppId } | Select-Object -First 1 + if (-not $apiSp) { + Write-Host "API service principal not found. Creating service principal for API app..." + $apiSp = New-MgServicePrincipal -AppId $apiApp.AppId + Start-Sleep -Seconds 5 + } + if ($apiSp) { + $portalConsentUrl = "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Permissions/appId/$($apiApp.AppId)/isMSAApp~/false" + Write-Host "" + Write-Host "ACTION REQUIRED: Grant admin consent for API app permissions in the Azure Portal:" -ForegroundColor Yellow + Write-Host "Open the following URL in your browser:" -ForegroundColor Yellow + Write-Host $portalConsentUrl -ForegroundColor Cyan + Write-Host "Then click 'Grant admin consent for ...' in the API permissions blade." -ForegroundColor Yellow + Write-Host "" + } + + return [PSCustomObject]@{ + App = $apiApp + AppId = $apiApp.AppId + ObjectId = $apiApp.Id + SpObjectId = if ($apiSp) { $apiSp.Id } else { "" } + Created = $created + } +} + +function New-WebRegistration { + param( + [string]$WebAppRegistrationName, + [string]$TenantId, + [string]$WebRedirectUri, + [string]$WebLogoutUri + ) + Write-Host "Checking for Web app registration: $WebAppRegistrationName..." + $webApp = Get-MgApplication -Filter "displayName eq '$WebAppRegistrationName'" -ErrorAction SilentlyContinue + $created = $false + $webSecret = $null + if (-not $webApp) { + Write-Host "Web app registration not found. Creating..." + try { + $webApp = New-MgApplication -DisplayName $WebAppRegistrationName -SignInAudience AzureADMyOrg -Web @{ RedirectUris = @($WebRedirectUri); LogoutUrl = $WebLogoutUri } + $created = $true + } + catch { + Write-Error "FATAL: Failed to create Web app registration. $_.Exception.Message" + exit 1 + } + if (-not $webApp -or -not $webApp.AppId) { + Write-Error "FATAL: Web app registration was not created. Exiting script." + exit 1 + } + Write-Host "Created Web app registration with appId: $($webApp.AppId)" + + Update-MgApplication -ApplicationId $webApp.Id -Web @{ ImplicitGrantSettings = @{ EnableIdTokenIssuance = $false; EnableAccessTokenIssuance = $true } } + Write-Host "Enabled access token issuance for implicit/hybrid flows in Web app registration." + + $webAppRoles = @( + @{ + Id = [Guid]::Parse("4c335757-d75a-4d79-8efc-ddcded89450b") + AllowedMemberTypes = @("User") + Description = "Admins have the ability to alter root setups that affect all tenants" + DisplayName = "Multi-tenant Admins" + IsEnabled = $true + Origin = "Application" + Value = "Asset.Admin" + } + ) + Update-MgApplication -ApplicationId $webApp.Id -AppRoles $webAppRoles + Write-Host "Added Asset.Admin app role to Web app registration." + + try { + $passwordCredential = @{ EndDateTime = (Get-Date).AddYears(2) } + $webSecretObj = Add-MgApplicationPassword -ApplicationId $webApp.Id -PasswordCredential $passwordCredential + } + catch { + Write-Error "FATAL: Failed to create client secret for Web app registration. $_.Exception.Message" + exit 1 + } + if (-not $webSecretObj -or -not $webSecretObj.SecretText) { + Write-Error "FATAL: Web app client secret was not created. Exiting script." + exit 1 + } + $webSecret = $webSecretObj.SecretText + Write-Host "Created client secret for Web app registration." + $webApp = Wait-ForApplicationPropagation -AppId $webApp.AppId -ObjectId $webApp.Id + } + else { + Write-Host "Web app registration $WebAppRegistrationName already exists." + } + Write-Host "Adding Microsoft Graph User.Read, email, profile delegated permissions to Web app registration..." + $msGraphSp = Get-MgServicePrincipal -Filter "displayName eq 'Microsoft Graph'" -ErrorAction Stop + if (-not $msGraphSp) { + Write-Error "FATAL: Microsoft Graph service principal not found. Exiting script." + exit 1 + } + $delegatedPerms = @("User.Read", "email", "profile") + $permScopes = @() + foreach ($perm in $delegatedPerms) { + $scope = $msGraphSp.Oauth2PermissionScopes | Where-Object { $_.Value -eq $perm } + if (-not $scope) { + Write-Error "FATAL: Microsoft Graph permission $perm not found. Exiting script." + exit 1 + } + $permScopes += @{ Id = $scope.Id; Type = "Scope" } + } + $webAppReqPerms = @() + $webAppReqPerms += @{ + ResourceAppId = $msGraphSp.AppId + ResourceAccess = $permScopes + } + $apiApp = Get-MgApplication -Filter "displayName eq '$ApiAppRegistrationName'" -ErrorAction Stop + if (-not $apiApp) { + Write-Error "FATAL: API app registration $ApiAppRegistrationName not found. Exiting script." + exit 1 + } + $customScopes = @("assets.read", "assets.write", "assets.delete") + $apiScopes = @() + foreach ($scope in $customScopes) { + $apiScope = $apiApp.Api.Oauth2PermissionScopes | Where-Object { $_.Value -eq $scope } + if (-not $apiScope) { + Write-Error "FATAL: API scope $scope not found in API app registration. Exiting script." + exit 1 + } + $apiScopes += @{ Id = $apiScope.Id; Type = "Scope" } + } + $webAppReqPerms += @{ + ResourceAppId = $apiApp.AppId + ResourceAccess = $apiScopes + } + Update-MgApplication -ApplicationId $webApp.Id -RequiredResourceAccess $webAppReqPerms + Write-Host "Added Microsoft Graph User.Read, email, profile delegated permissions to Web app registration." + + $webApp = Wait-ForApplicationPropagation -AppId $webApp.AppId -ObjectId $webApp.Id + $webSp = Get-MgServicePrincipal -All | Where-Object { $_.AppId -eq $webApp.AppId } | Select-Object -First 1 + if (-not $webSp) { + Write-Host "Web service principal not found. Creating service principal for Web app..." + $webSp = New-MgServicePrincipal -AppId $webApp.AppId + Start-Sleep -Seconds 5 + } + if ($webSp) { + $portalConsentUrl = "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Permissions/appId/$($webApp.AppId)/isMSAApp~/false" + Write-Host "" + Write-Host "ACTION REQUIRED: Grant admin consent for Web app permissions in the Azure Portal:" -ForegroundColor Yellow + Write-Host "Open the following URL in your browser:" -ForegroundColor Yellow + Write-Host $portalConsentUrl -ForegroundColor Cyan + Write-Host "Then click 'Grant admin consent for ...' in the API permissions blade." -ForegroundColor Yellow + Write-Host "" + } + + $optionalClaims = @{ + idToken = @( + @{ name = "ctry" }, + @{ name = "email" }, + @{ name = "family_name" }, + @{ name = "given_name" }, + @{ name = "ipaddr" }, + @{ name = "preferred_username" }, + @{ name = "upn" } + ) + } + Update-MgApplication -ApplicationId $webApp.Id -OptionalClaims $optionalClaims + Write-Host "Added optional claims (ctry, email, family_name, given_name, ipaddr, preferred_username, upn) to Web app registration." + + $webApp = Wait-ForApplicationPropagation -AppId $webApp.AppId + $webSp = Get-MgServicePrincipal -All | Where-Object { $_.AppId -eq $webApp.AppId } | Select-Object -First 1 + return [PSCustomObject]@{ + App = $webApp + AppId = $webApp.AppId + ObjectId = $webApp.Id + SpObjectId = if ($webSp) { $webSp.Id } else { "" } + Created = $created + Secret = $webSecret + } +} + +function New-Auth { + param( + [string]$TenantId + ) + function New-AzLogin { + param([string]$TenantId) + Write-Host "Checking Azure authentication..." + $azContext = Get-AzContext -ErrorAction SilentlyContinue + if ($azContext -and $azContext.Tenant.Id -eq $TenantId) { + Write-Host "Already authenticated to Azure tenant $TenantId." + } + else { + Write-Host "Logging into Azure..." + Connect-AzAccount -Tenant $TenantId | Out-Null + Write-Host "Logged in to Azure tenant $TenantId." + } + } + function New-MgLogin { + param([string]$TenantId) + $mgContext = $null + try { + $mgContext = Get-MgContext -ErrorAction Stop + } + catch {} + if ($mgContext -and $mgContext.TenantId -eq $TenantId -and $mgContext.Account) { + Write-Host "Already authenticated to Microsoft Graph for tenant $TenantId." + } + else { + try { + Write-Host "Connecting to Microsoft Graph..." + Connect-MgGraph -TenantId $TenantId -Scopes "Application.ReadWrite.All", "Directory.ReadWrite.All" | Out-Null + $mgContext = Get-MgContext -ErrorAction Stop + Write-Host "Connected to Microsoft Graph for tenant $TenantId." + } + catch { + Write-Error "FATAL: Microsoft Graph authentication failed. Exiting script." + exit 1 + } + } + } + New-AzLogin -TenantId $TenantId + New-MgLogin -TenantId $TenantId +} + +function Install-Prerequisites { + param( + [string]$DotNetVersion + ) + Write-Host "Checking prerequisites..." + $dotnetInstalled = & dotnet --list-sdks | Select-String "^$DotNetVersion\." + if (-not $dotnetInstalled) { + Write-Host ".NET SDK $DotNetVersion not found. Installing via winget..." + winget install --id Microsoft.DotNet.SDK.$DotNetVersion -e --silent + } + else { + Write-Host ".NET SDK $DotNetVersion is already installed." + } + $modules = @("Az.Accounts", "Az.Resources", "Microsoft.Graph.Applications") + foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "Installing PowerShell module: $module" + Install-Module $module -Scope CurrentUser -Force + } + else { + Write-Host "PowerShell module $module is already installed." + } + } + # Import modules here so they're available everywhere + Import-Module Az.Accounts -ErrorAction Stop + Import-Module Az.Resources -ErrorAction Stop + Import-Module Microsoft.Graph.Applications -ErrorAction Stop +} + +function Set-ProjectUserSecrets { + param( + [string]$ProjectPath, + [hashtable]$Secrets + ) + if (Test-Path $ProjectPath) { + Write-Host "Setting EntraExternalId values for $ProjectPath" + Push-Location $ProjectPath + dotnet user-secrets init + foreach ($key in $Secrets.Keys) { + dotnet user-secrets set $key $Secrets[$key] + } + Pop-Location + } + else { + Write-Warning "*** CRITICAL: Project path '$ProjectPath' not found. Skipping dotnet user-secrets. App registrations will continue, but user-secrets are NOT set. ***" + } +} + +function Wait-ForApplicationPropagation { + param( + [string]$AppId, + [string]$ObjectId = $null, + [int]$MaxRetries = 10, + [int]$DelaySeconds = 2 + ) + $retryCount = 0 + $app = $null + do { + Start-Sleep -Seconds $DelaySeconds + if ($ObjectId) { + $app = Get-MgApplication -ApplicationId $ObjectId -ErrorAction SilentlyContinue + } + if (-not $app -and $AppId) { + $app = Get-MgApplication -Filter "appId eq '$AppId'" -ErrorAction SilentlyContinue + } + $retryCount++ + } while (-not $app -and $retryCount -lt $MaxRetries) + if (-not $app) { + Write-Error "FATAL: Application $AppId was not found after creation and waiting. Exiting script." + exit 1 + } + return $app +} + +function Write-OutputSummary { + param( + [string]$TenantId, + [string]$EntraInstanceUrl, + [object]$ApiApp, + [object]$WebApp, + [string]$WebRedirectUri, + [string]$WebLogoutUri + ) + Write-Host "`n================= OUTPUT SUMMARY =================" + Write-Host "TenantId: $TenantId" + Write-Host "Instance: $EntraInstanceUrl" + Write-Host "API AppId: $($ApiApp.AppId)" + Write-Host "API ObjectId: $($ApiApp.ObjectId)" + Write-Host "API Service Principal ObjectId: $($ApiApp.SpObjectId)" + Write-Host "Web AppId: $($WebApp.AppId)" + Write-Host "Web ObjectId: $($WebApp.ObjectId)" + Write-Host "Web Service Principal ObjectId: $($WebApp.SpObjectId)" + Write-Host "Web Redirect URI: $WebRedirectUri" + Write-Host "Web Logout URI: $WebLogoutUri" + Write-Host "=================================================`n" +} + +# Step 1: Install prerequisites +Install-Prerequisites -DotNetVersion $DotNetVersion + +# Step 2: Authenticate to Azure and Microsoft Graph +New-Auth -TenantId $TenantId + +# Step 3: Create or get API app registration using function +$apiReg = New-ApiRegistration -ApiAppRegistrationName $ApiAppRegistrationName -TenantId $TenantId +$apiApp = $apiReg + +# Step 4: Write API EEID values to $ApiProjectPath via dotnet user-secrets +$apiSecrets = @{ + "EntraExternalId:Instance" = $EntraInstanceUrl + "EntraExternalId:TenantId" = $TenantId + "EntraExternalId:ClientId" = $apiApp.AppId + "EntraExternalId:ValidateAuthority" = "true" +} +Set-ProjectUserSecrets -ProjectPath $ApiProjectPath -Secrets $apiSecrets + +# Step 5: Create or get Web app registration using function and capture output +$webReg = New-WebRegistration -WebAppRegistrationName $WebAppRegistrationName -TenantId $TenantId -WebRedirectUri $WebRedirectUri -WebLogoutUri $WebLogoutUri +$webApp = $webReg + +# Step 6: Write Web EEID values to $WebProjectPath via dotnet user-secrets +$webSecrets = @{ + "BackEndApi:ClientId" = $apiApp.AppId + "EntraExternalId:Instance" = $EntraInstanceUrl + "EntraExternalId:TenantId" = $TenantId + "EntraExternalId:ClientId" = $webApp.AppId + "EntraExternalId:ValidateAuthority" = "true" + "EntraExternalId:ClientSecret" = $webApp.Secret +} +Set-ProjectUserSecrets -ProjectPath $WebProjectPath -Secrets $webSecrets + +# Step 7: Output summary of created app registrations +Write-OutputSummary -TenantId $TenantId -EntraInstanceUrl $EntraInstanceUrl -ApiApp $apiApp -WebApp $webApp -WebRedirectUri $WebRedirectUri -WebLogoutUri $WebLogoutUri \ No newline at end of file diff --git a/.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 b/.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 new file mode 100644 index 0000000..05374d2 --- /dev/null +++ b/.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 @@ -0,0 +1,78 @@ +# ============================================================================ +# Script Name: Set-ApiAppUserSecrets.ps1 +# Description: Reads an existing API App Registration and sets .NET user-secrets. +# ----------------------------------------------------------------------------- +# Example CLI Usage: +# pwsh -File ./Set-ApiAppUserSecrets.ps1 ` +# -TenantId "" ` +# -ApiAppRegistrationName "myproduct-api-dev-001" ` +# -ApiProjectPath "../../src/Presentation.WebApi" +# ----------------------------------------------------------------------------- +# Notes: +# - Requires Azure PowerShell modules (Az.Accounts, Microsoft.Graph.Applications) +# - Ensure you are authenticated: Connect-AzAccount +# - This script does NOT create or modify app registrations. +# ============================================================================ + +param( + [string]$TenantId, + [string]$ApiAppRegistrationName, + [string]$ApiProjectPath +) + +function Install-Prerequisites { + $modules = @("Az.Accounts", "Microsoft.Graph.Applications") + foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "Installing PowerShell module: $module" + Install-Module $module -Scope CurrentUser -Force + } + } + Import-Module Az.Accounts -ErrorAction Stop + Import-Module Microsoft.Graph.Applications -ErrorAction Stop +} + +function New-Auth { + param([string]$TenantId) + $azContext = Get-AzContext -ErrorAction SilentlyContinue + if (-not $azContext -or $azContext.Tenant.Id -ne $TenantId) { + Connect-AzAccount -Tenant $TenantId | Out-Null + } + $mgContext = $null + try { $mgContext = Get-MgContext -ErrorAction Stop } catch {} + if (-not $mgContext -or $mgContext.TenantId -ne $TenantId -or -not $mgContext.Account) { + Connect-MgGraph -TenantId $TenantId -Scopes "Application.Read.All" | Out-Null + } +} + +function Set-ProjectUserSecrets { + param([string]$ProjectPath, [hashtable]$Secrets) + if (Test-Path $ProjectPath) { + Push-Location $ProjectPath + dotnet user-secrets init + foreach ($key in $Secrets.Keys) { + dotnet user-secrets set $key $Secrets[$key] + } + Pop-Location + Write-Host "Secrets set for $ProjectPath" + } else { + Write-Warning "*** Project path '$ProjectPath' not found. Skipping dotnet user-secrets. ***" + } +} + +Install-Prerequisites +New-Auth -TenantId $TenantId + +$apiApp = Get-MgApplication -Filter "displayName eq '$ApiAppRegistrationName'" -ErrorAction Stop +if (-not $apiApp) { + Write-Error "API app registration '$ApiAppRegistrationName' not found." + exit 1 +} + +$apiSecrets = @{ + "EntraExternalId:Instance" = "https://login.microsoftonline.com" + "EntraExternalId:TenantId" = $TenantId + "EntraExternalId:ClientId" = $apiApp.AppId + "EntraExternalId:ValidateAuthority" = "true" +} +Set-ProjectUserSecrets -ProjectPath $ApiProjectPath -Secrets $apiSecrets \ No newline at end of file diff --git a/.azure/scripts/entra/Set-WebAppUserSecrets.ps1 b/.azure/scripts/entra/Set-WebAppUserSecrets.ps1 new file mode 100644 index 0000000..48475af --- /dev/null +++ b/.azure/scripts/entra/Set-WebAppUserSecrets.ps1 @@ -0,0 +1,84 @@ +# ============================================================================ +# Script Name: Set-WebAppUserSecrets.ps1 +# Description: Reads an existing Web App Registration and sets .NET user-secrets. +# ----------------------------------------------------------------------------- +# Example CLI Usage: +# pwsh -File ./Set-WebAppUserSecrets.ps1 ` +# -TenantId "" ` +# -WebAppRegistrationName "myproduct-web-dev-001" ` +# -WebProjectPath "../../src/Presentation.Blazor" +# ----------------------------------------------------------------------------- +# Notes: +# - Requires Azure PowerShell modules (Az.Accounts, Microsoft.Graph.Applications) +# - Ensure you are authenticated: Connect-AzAccount +# - This script does NOT create or modify app registrations. +# ============================================================================ + +param( + [string]$TenantId, + [string]$WebAppRegistrationName, + [string]$WebProjectPath +) + +function Install-Prerequisites { + $modules = @("Az.Accounts", "Microsoft.Graph.Applications") + foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "Installing PowerShell module: $module" + Install-Module $module -Scope CurrentUser -Force + } + } + Import-Module Az.Accounts -ErrorAction Stop + Import-Module Microsoft.Graph.Applications -ErrorAction Stop +} + +function New-Auth { + param([string]$TenantId) + $azContext = Get-AzContext -ErrorAction SilentlyContinue + if (-not $azContext -or $azContext.Tenant.Id -ne $TenantId) { + Connect-AzAccount -Tenant $TenantId | Out-Null + } + $mgContext = $null + try { $mgContext = Get-MgContext -ErrorAction Stop } catch {} + if (-not $mgContext -or $mgContext.TenantId -ne $TenantId -or -not $mgContext.Account) { + Connect-MgGraph -TenantId $TenantId -Scopes "Application.Read.All" | Out-Null + } +} + +function Set-ProjectUserSecrets { + param([string]$ProjectPath, [hashtable]$Secrets) + if (Test-Path $ProjectPath) { + Push-Location $ProjectPath + dotnet user-secrets init + foreach ($key in $Secrets.Keys) { + dotnet user-secrets set $key $Secrets[$key] + } + Pop-Location + Write-Host "Secrets set for $ProjectPath" + } else { + Write-Warning "*** Project path '$ProjectPath' not found. Skipping dotnet user-secrets. ***" + } +} + +Install-Prerequisites +New-Auth -TenantId $TenantId + +$webApp = Get-MgApplication -Filter "displayName eq '$WebAppRegistrationName'" -ErrorAction Stop +if (-not $webApp) { + Write-Error "Web app registration '$WebAppRegistrationName' not found." + exit 1 +} + +$secrets = Get-MgApplicationPassword -ApplicationId $webApp.Id +$clientSecret = $secrets | Select-Object -First 1 -ExpandProperty SecretText + +$webSecrets = @{ + "EntraExternalId:Instance" = "https://login.microsoftonline.com" + "EntraExternalId:TenantId" = $TenantId + "EntraExternalId:ClientId" = $webApp.AppId + "EntraExternalId:ValidateAuthority" = "true" +} +if ($clientSecret) { + $webSecrets["EntraExternalId:ClientSecret"] = $clientSecret +} +Set-ProjectUserSecrets -ProjectPath $WebProjectPath -Secrets $webSecrets \ No newline at end of file diff --git a/.azure/scripts/hub-and-spoke/New-PlatformHub.ps1 b/.azure/scripts/hub-and-spoke/New-PlatformHub.ps1 new file mode 100644 index 0000000..c783e57 --- /dev/null +++ b/.azure/scripts/hub-and-spoke/New-PlatformHub.ps1 @@ -0,0 +1,32 @@ +# Login and set subscription variables +az login + +# Sentinel requires registration +az provider register --namespace Microsoft.OperationsManagement +az provider register --namespace Microsoft.SecurityInsights + + +$mgmtRg = "COMPANY-hubmgmt-wus2-001-rg" +$mgmtTemplate = "../bicep/templates/platform-hub-mgmt.bicep" +$mgmtParams = "../bicep/variables/platform-hub-mgmt.bicepparam" +$networkRg = "COMPANY-hubnetwork-wus2-001-rg" +$networkTemplate = "../bicep/templates/platform-hub-network-publicroute.bicep" +$networkParams = "../bicep/variables/platform-hub-network-publicroute.bicepparam" +$hubSubId = "" + +# Create resource groups if not exist +az group create --name $mgmtRg --location westus2 +az group create --name $networkRg --location westus2 + +# Management group deployment: what-if, then deploy if OK +az account set --subscription $hubSubId +az deployment group what-if --resource-group $mgmtRg --template-file $mgmtTemplate --parameters @$mgmtParams +if ($LASTEXITCODE -eq 0) { + az deployment group create --resource-group $mgmtRg --template-file $mgmtTemplate --parameters @$mgmtParams +} + +# Network group deployment: what-if, then deploy if OK +az deployment group what-if --resource-group $networkRg --template-file $networkTemplate --parameters @$networkParams +if ($LASTEXITCODE -eq 0) { + az deployment group create --resource-group $networkRg --template-file $networkTemplate --parameters @$networkParams +} diff --git a/.azure/scripts/hub-and-spoke/New-PlatformSpoke.ps1 b/.azure/scripts/hub-and-spoke/New-PlatformSpoke.ps1 new file mode 100644 index 0000000..a303a0b --- /dev/null +++ b/.azure/scripts/hub-and-spoke/New-PlatformSpoke.ps1 @@ -0,0 +1,27 @@ +# Login and set subscription variables +az login + +$mgmtRg = "COMPANY-hubmgmt-wus2-001-rg" +$mgmtTemplate = "../bicep/templates/platform-spoke-mgmt.bicep" +$mgmtParams = "../bicep/variables/platform-spoke-mgmt.bicepparam" +$networkRg = "COMPANY-hubnetwork-wus2-001-rg" +$networkTemplate = "../bicep/templates/platform-spoke-network.bicep" +$networkParams = "../bicep/variables/platform-spoke-network.bicepparam" +$spokeSubId = "" + +# Create resource groups if not exist +az group create --name $mgmtRg --location westus2 +az group create --name $networkRg --location westus2 + +# Management group deployment: what-if, then deploy if OK +az account set --subscription $spokeSubId +az deployment group what-if --resource-group $mgmtRg --template-file $mgmtTemplate --parameters @$mgmtParams +if ($LASTEXITCODE -eq 0) { + az deployment group create --resource-group $mgmtRg --template-file $mgmtTemplate --parameters @$mgmtParams +} + +# Network group deployment: what-if, then deploy if OK +az deployment group what-if --resource-group $networkRg --template-file $networkTemplate --parameters @$networkParams +if ($LASTEXITCODE -eq 0) { + az deployment group create --resource-group $networkRg --template-file $networkTemplate --parameters @$networkParams +} diff --git a/.azure/scripts/hub-and-spoke/New-VnetPeering.ps1 b/.azure/scripts/hub-and-spoke/New-VnetPeering.ps1 new file mode 100644 index 0000000..52e3ee9 --- /dev/null +++ b/.azure/scripts/hub-and-spoke/New-VnetPeering.ps1 @@ -0,0 +1,55 @@ +# Log in to Azure +az login + +# Login +az login + +# Variables for resource groups, templates, and parameters +$mgmtRg = "can-hubmgmt-plat-wus2-001-rg" +$mgmtTemplate = "bicep/templates/platform-hub-publicroute-mgmt.bicep" +$mgmtParams = "bicep/variables/platform-hub-publicroute-mgmt.bicepparam" +$networkRg = "can-hubnetwork-plat-wus2-001-rg" +$networkTemplate = "bicep/templates/platform-hub-publicroute-network.bicep" +$networkParams = "bicep/variables/platform-hub-publicroute-network.bicepparam" +$hubSubId = "" +$spokeSubId = "" +$spokeRg = "" +$hubVnetName = "" +$spokeVnetName = "" +$hubVnetResourceId = "/subscriptions/$hubSubId/resourceGroups/$networkRg/providers/Microsoft.Network/virtualNetworks/$hubVnetName" +$spokeVnetResourceId = "/subscriptions/$spokeSubId/resourceGroups/$spokeRg/providers/Microsoft.Network/virtualNetworks/$spokeVnetName" +$spokeToHubPeeringName = "spoke-to-hub" +$hubToSpokePeeringName = "hub-to-spoke" +$peeringTemplate = "bicep/modules/vnetpeer-virtualnetworkpeering.bicep" + +# Create resource groups if not exist +az group create --name $mgmtRg --location westus2 +az group create --name $networkRg --location westus2 +az group create --name $spokeRg --location westus2 + +# Management group deployment: what-if, then deploy if OK +az account set --subscription $hubSubId +az deployment group what-if --resource-group $mgmtRg --template-file $mgmtTemplate --parameters @$mgmtParams +if ($LASTEXITCODE -eq 0) { + az deployment group create --resource-group $mgmtRg --template-file $mgmtTemplate --parameters @$mgmtParams +} + +# Network group deployment: what-if, then deploy if OK +az deployment group what-if --resource-group $networkRg --template-file $networkTemplate --parameters @$networkParams +if ($LASTEXITCODE -eq 0) { + az deployment group create --resource-group $networkRg --template-file $networkTemplate --parameters @$networkParams +} + +# Spoke to Hub Peering +az account set --subscription $spokeSubId +az deployment group create ` + --resource-group $spokeRg ` + --template-file $peeringTemplate ` + --parameters localVnetName=$spokeVnetName peeringName=$spokeToHubPeeringName remoteVnetId=$hubVnetResourceId allowGatewayTransit=false useRemoteGateways=true allowVnetAccess=true allowForwardedTraffic=false + +# Hub to Spoke Peering +az account set --subscription $hubSubId +az deployment group create ` + --resource-group $networkRg ` + --template-file $peeringTemplate ` + --parameters localVnetName=$hubVnetName peeringName=$hubToSpokePeeringName remoteVnetId=$spokeVnetResourceId allowGatewayTransit=true useRemoteGateways=false allowVnetAccess=true allowForwardedTraffic=false diff --git a/.azure/templates/landingzone-standalone-web-api-sql.bicep b/.azure/templates/landingzone-standalone-web-api-sql.bicep new file mode 100644 index 0000000..e9ea150 --- /dev/null +++ b/.azure/templates/landingzone-standalone-web-api-sql.bicep @@ -0,0 +1,145 @@ +targetScope = 'resourceGroup' + +@description('The Azure region where resources will be deployed. Only allowed regions for SQL module.') +@allowed([ + 'eastus' + 'eastus2' + 'centralus' + 'westus' + 'westus2' +]) +param location string = 'eastus' + +@description('Resource tags to be applied to all resources.') +param tags object + +@minLength(4) +@maxLength(63) +@description('Specifies the name of the Log Analytics workspace. 4-63 characters, letters, numbers, and -') +param workName string + +@allowed(['PerGB2018', 'Free']) +@description('SKU for the Log Analytics workspace. Allowed: PerGB2018, Free.') +param workSku string = 'PerGB2018' + +@minLength(1) +@maxLength(255) +@description('Specifies the name of the Application Insights resource. 1-255 characters, letters, numbers, and -') +param appiName string + +@allowed(['F1', 'B1', 'B2', 'B3', 'D1', 'P1', 'P2', 'P3', 'P4', 'S1', 'S2', 'S3', 'Y1']) +@description('SKU for the App Service Plan. Allowed: F1, B1, B2, B3, D1, P1, P2, P3, P4, S1, S2, S3, Y1.') +param planSku string = 'F1' + +@minLength(1) +@maxLength(40) +@description('Name of the App Service Plan. 1-40 characters.') +param planName string + +@minLength(1) +@maxLength(40) +@description('Environment name for the application. 1-40 characters.') +param environmentApp string + +@minLength(1) +@maxLength(60) +@description('Name of the Web App. 1-60 characters.') +param webName string + +@minLength(1) +@maxLength(60) +@description('Name of the API App. 1-60 characters.') +param apiName string + +@minLength(1) +@maxLength(60) +@description('Name of the SQL Server. 1-60 characters.') +param sqlName string + +@minLength(1) +@maxLength(60) +@description('SQL Server admin username. 1-60 characters.') +param sqlAdminUser string + +@secure() +@minLength(8) +@maxLength(60) +@description('SQL Server admin password. 8-60 characters.') +param sqlAdminPassword string + +@minLength(1) +@maxLength(60) +@description('Name of the SQL Database. 1-60 characters.') +param sqldbName string + +@allowed(['Basic', 'Premium', 'Standard']) +@description('SKU for the SQL Database. Allowed: Basic, Premium, Standard.') +param sqldbSku string = 'Basic' + +module workModule '../modules/sent-loganalyticsworkspace.bicep' = { + name: 'workName' + params: { + name: workName + location: location + tags: tags + sku: workSku + } +} + +module appiModule '../modules/appi-applicationinsights.bicep' = { + name: 'appiName' + params:{ + location: location + tags: tags + name: appiName + workResourceId: workModule.outputs.id + } +} + +module planModule '../modules/plan-appserviceplan.bicep' = { + name: 'planModule' + params: { + name: planName + sku: planSku + location: location + } +} + +module apiModule '../modules/api-appservice.bicep' = { + name: 'apiModuleName' + params:{ + name: apiName + location: location + tags: tags + environment: environmentApp + appiKey:appiModule.outputs.InstrumentationKey + appiConnection:appiModule.outputs.Connectionstring + planId: planModule.outputs.id + } +} + +module webModule '../modules/web-appservice.bicep' = { + name: 'webModuleName' + params:{ + name: webName + location: location + tags: tags + environment: environmentApp + appiKey:appiModule.outputs.InstrumentationKey + appiConnection:appiModule.outputs.Connectionstring + planId: planModule.outputs.id + } +} + +module sqlModule '../modules/sql-sqlserverdatabase.bicep' = { + name: 'sqlModuleName' + params:{ + name: sqlName + location: location + tags: tags + adminLogin: sqlAdminUser + adminPassword: sqlAdminPassword + sqldbName: sqldbName + sku: sqldbSku + } +} diff --git a/.azure/variables/landingzone-standalone-web-api-sql-dev.bicepparam b/.azure/variables/landingzone-standalone-web-api-sql-dev.bicepparam new file mode 100644 index 0000000..1135844 --- /dev/null +++ b/.azure/variables/landingzone-standalone-web-api-sql-dev.bicepparam @@ -0,0 +1,29 @@ +using '../templates/landingzone-standalone-web-api-sql.bicep' + +// Common +var productIac = 'semker' +var environmentIac = 'dev' +var regionIac = 'wus2' +var instanceIac = '001' +var planSku = 'F1' + +param environmentApp = 'Development' +param location = 'westus2' +param tags = { Environment: environmentIac, CostCenter: '0000' } + +// Common Services +param appiName = '${productIac}-${environmentIac}-${regionIac}-${instanceIac}-appi' +param workName = '${productIac}-${environmentIac}-${regionIac}-${instanceIac}-appi' + + +// App Service +param webName = '${productIac}-${environmentIac}-${regionIac}-${instanceIac}-web' +param apiName = '${productIac}-${environmentIac}-${regionIac}-${instanceIac}-api' +param planName = '${productIac}-${environmentIac}-${regionIac}-${planSku}-${instanceIac}-plan' + +// SQL Server +param sqlName = '${productIac}-${environmentIac}-${regionIac}-${instanceIac}-sql' +param sqlAdminUser = 'LocalAdmin' +param sqlAdminPassword = 'PASS_FROM_CLI_PARAMETERS' +param sqldbName = '${productIac}-${environmentIac}-${regionIac}-${instanceIac}-sqldb' +param sqldbSku = 'Basic' diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..c49339d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,43 @@ +# Copilot Instructions for Microsoft Agent Framework Quick-start + +## Project Overview +- **Microsoft Agent Framework Quick-start** is a C# solution for AI-powered monitoring, classification, and mitigation of digital assets. +- Built on a clean architecture: ASP.NET Core Web API backend, Blazor WebAssembly frontend, SQL Server storage, and Microsoft Semantic Kernel for AI/LLM integration. +- Infrastructure is managed via Azure Bicep and deployed using GitHub Actions. + +## Key Architectural Patterns +- **Frontend:** `src/Presentation.Blazor/` (Blazor WebAssembly) +- **Backend:** `src/Presentation.WebApi/` (ASP.NET Core Web API) +- **Core Logic:** `src/Core.Application/`, `src/Core.Domain/` +- **AI Integration:** `src/Infrastructure.SemanticKernel/` (Semantic Kernel plugins, prompt orchestration) +- **Persistence:** `src/Infrastructure.SqlServer/` (SQL Server, migrations) +- **IaC:** `data/` (SQL), Azure Bicep in deployment scripts + +## Developer Workflows +- **Build:** Use `build.cmd` or `build.sh` in `src/` to build the solution. +- **Test:** Integration specs in `src/gherkin Tests.Specs.Integration/` and `src/Tests.Specs.Integration/`. +- **Run:** Launch via Visual Studio or `dotnet run` from solution root. +- **CI/CD:** Managed by GitHub Actions (`.github/workflows/`). +- **IaC Deploy:** See `can-digital-insights-iac.yml` for infrastructure deployment. + +## Naming & Conventions +- **C# code:** PascalCase for types/methods/properties, _camelCase for private fields, camelCase for locals. +- **Database:** PascalCase plural for tables, PascalCase for columns, `Id` for PK, `RelatedEntityId` for FK. +- **API:** Kebab-case, plural nouns for endpoints (e.g., `/api/digital-agents`). +- **Files/Folders:** C# files match main class, folders use PascalCase, config/docs use lowercase-hyphens. +- See `docs/naming-conventions.md` for details. + +## Integration & Extensibility +- **Semantic Kernel:** Add plugins in `src/Infrastructure.SemanticKernel/Plugins/`. +- **External Integrations:** Use `src/Core.Application/Common/` and `src/Infrastructure.SemanticKernel/` for connectors. +- **RBAC & Security:** Enforced in API layer, see `ConfigureServicesAuth.cs`. + +## References +- [README.md](../README.md): Project overview and getting started + +## Examples +- To add a new agent plugin: create in `src/Infrastructure.SemanticKernel/Plugins/`, register in `ConfigureServices.cs`. +- To add a new API endpoint: implement in `src/Presentation.WebApi/`, follow API naming conventions. + +--- +For further details, always check the referenced docs and existing code patterns in the relevant folders. \ No newline at end of file diff --git a/.github/scripts/System.psm1 b/.github/scripts/System.psm1 new file mode 100644 index 0000000..c9cafd5 --- /dev/null +++ b/.github/scripts/System.psm1 @@ -0,0 +1,1500 @@ +#----------------------------------------------------------------------- +# Add-Prefix [-String []] +# +# Example: .\Add-Prefix -String ello -Add H +# Result: Hello +#----------------------------------------------------------------------- +function Add-Prefix +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Add = $(throw '-Add is a required parameter.') + ) + Write-Verbose "Add-Prefix -String $String -Add $Add" + [string]$ReturnValue = $String + if (-not (Compare-IsFirst -String $String -BeginsWith $Add)) + { + $ReturnValue = ($Add + $ReturnValue) + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -String $String already has prefix of -Add $Add" + } + return $ReturnValue +} +export-modulemember -function Add-Prefix + +#----------------------------------------------------------------------- +# Add-Suffix [-String []] +# +# Example: .\Add-Suffix -String Hell -Add o +# Result: Hello +#----------------------------------------------------------------------- +function Add-Suffix +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Add = $(throw '-Add is a required parameter.') + ) + Write-Verbose "Add-Suffix -String $String -Add $Add" + [string]$ReturnValue = $String + if (-not (Compare-IsLast -String $String -EndsWith $Add)) + { + $ReturnValue = ($String + $Add) + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -String $String already has suffix of -Add $Add" + } + + return $ReturnValue +} +export-modulemember -function Add-Suffix + +#----------------------------------------------------------------------- +# Compare-IsFirst [-String []] +# +# Example: .\Compare-IsFirst -String Hell -EndsWith H +# Result: false +# Example: .\Compare-IsFirst -String Hello -Add H +# Result: Hello +#----------------------------------------------------------------------- +function Compare-IsFirst +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$BeginsWith = $(throw '-BeginsWith is a required parameter.') + ) + + Write-Verbose "Compare-IsFirst -String $String -EndsWith $EndsWith" + [Boolean]$ReturnValue = $false + if($BeginsWith.Length -lt $String.Length) + { + $StringBeginning = $String.SubString(0, $BeginsWith.Length).ToLower() + if ($StringBeginning.ToLower().Equals($BeginsWith.ToLower())) + { + $ReturnValue = $true + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + return $ReturnValue +} +export-modulemember -function Compare-IsFirst + +#----------------------------------------------------------------------- +# Compare-IsLast [-String []] +# +# Example: .\Compare-IsLast -String Hell -EndsWith H +# Result: false +# Example: .\Compare-IsLast -String Hello -Add H +# Result: Hello +#----------------------------------------------------------------------- +function Compare-IsLast +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$EndsWith = $(throw '-EndsWith is a required parameter.') + ) + Write-Verbose "Compare-IsLast -String $String -EndsWith $EndsWith" + [Boolean]$ReturnValue = $false + if($EndsWith.Length -lt $String.Length) + { + $StringEnding = $String.SubString(($String.Length - $EndsWith.Length), $EndsWith.Length) + if ($StringEnding.ToLower().Equals($EndsWith.ToLower())) + { + $ReturnValue = $true + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + + return $ReturnValue +} +export-modulemember -function Compare-IsLast + +#----------------------------------------------------------------------- +# Compress-Path [-Path []] [-File []] +# +# Example: .\Compress-Path \\source\path \\destination\path\file.zip +#----------------------------------------------------------------------- +function Compress-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$File=$(throw '-File is a required parameter.') + ) + Write-Verbose "Compress-Path -Path $Path -File $File" + New-Path -Path $Path + Remove-File $File + [Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") + [System.IO.Compression.ZipFile]::CreateFromDirectory($Path, $File) +} +export-modulemember -function Compress-Path + +#----------------------------------------------------------------------- +# Convert-PathSafe [-Path []] +# +# Example: .\Convert-PathSafe -Path \\source\path +#----------------------------------------------------------------------- +function Convert-PathSafe +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Convert-PathSafe -Path $Path" + $Path = $Path.Trim() + $ReturnValue = $Path + $Path = Set-Unc -Path $Path + if(Test-Path -Path $Path) + { + $ReturnValue = Convert-Path -Path $Path + if (-not ($ReturnValue)) + { + $ReturnValue = $Path + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path didnt convert." + } + } + else + { + Write-Verbose "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } + return $ReturnValue +} +export-modulemember -function Convert-PathSafe + +#----------------------------------------------------------------------- +# Copy-Backup [-Path []] [-Destination []] +# +# Example: .\Copy-Backup -Path \\source\path -Destination \\destination\path +#----------------------------------------------------------------------- +function Copy-Backup +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Destination = $(throw '-Destination is a required parameter.') + ) + Write-Verbose "Copy-Backup -Path $Path -Destination $Destination" + $Path = Remove-Suffix -String $Path -Remove "\" + New-Path -Path $Destination + [String]$BackupPath=[string]::Format("{0}\{1}", $Destination, (Get-Date).ToString("yyyy-MM-dd")) + if($Path) + { + if(-not (Test-Path -Path $BackupPath -PathType Container)){ + New-Path -Path $BackupPath + } + Copy-Recurse -Path $Path -Destination $BackupPath + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path to -Destination $BackupPath" + } + else + { + Write-Verbose "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Copy-Backup + +#----------------------------------------------------------------------- +# Copy-File [-Path []] [-Destination []] +# +# Example: .\Copy-File -Path \\source\path\File.name -Destination \\destination\path +#----------------------------------------------------------------------- +function Copy-File +{ + param ( + [Parameter(Mandatory = $True)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory = $True)] + [string]$Destination = $(throw '-Destination is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [bool]$Overwrite = $true + ) + Write-Verbose "Copy-File -Path $Path -Destination $Destination -Overwrite $Overwrite" + $Destination = Set-Unc -Path $Destination + if(Test-File -Path $Path) + { + New-Path -Path $Destination + $DestinationAbsolute = $Destination + if(Test-Folder -Path $Destination) + { + $DestinationAbsolute = Convert-PathSafe -Path $Destination + } + $DestinationPathFile = $DestinationAbsolute + $FolderArray = $Path.Split('\') + if($FolderArray.Count -gt 0) + { + $DestinationPathFile = Join-Path $DestinationAbsolute $FolderArray[$FolderArray.Count-1] + } + if((-not (Test-Path $DestinationPathFile -PathType Leaf)) -or ($Overwrite -eq $true)) + { + try{ + Copy-Item -Path $Path -Destination $DestinationAbsolute -Include $Include -Exclude $Exclude -Force + } + catch{ + Write-Verbose "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path to -Destination $DestinationAbsolute" + } + else + { + Write-Verbose "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Copy-File + +#----------------------------------------------------------------------- +# Copy-Recurse [-Source []] [-Destination []] +# [-Include [] [-Exclude []] +# +# Example: .\Copy-Recurse \\source\path \\destination\path +#----------------------------------------------------------------------- +function Copy-Recurse +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Source is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Destination = $(throw '-Destination is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 1000, + [bool]$Overwrite=$True, + [bool]$Clean = $False + ) + Write-Verbose "Copy-Recurse -Path $Path -Destination $Destination -Include $Include -Exclude $Exclude -First $ -Overwrite $Overwrite -Clean $Clean" + $Affected = 0 + $Path = Set-Unc -Path $Path + if (Test-Path $Path) + { + $PathAbsolute = Convert-PathSafe -Path $Path + # Optionally Clean + if($Clean -eq $True) { Remove-Path -Path $Destination } + New-Path -Path $Destination + $DestinationAbsolute = $Destination + if(Test-Path $Destination) { $DestinationAbsolute=Convert-PathSafe -Path $Destination } + $Items = Get-ChildItem -Path $PathAbsolute -Recurse -Include $Include -Exclude $Exclude | where { ! $_.PSIsContainer } + ForEach ($Item in $Items) { + $PathArray = $PathAbsolute.Split('\') + $Folder = $DestinationAbsolute + for ($count=1; $count -lt $PathArray.length-1; $count++) { + $Subfolder = $PathArray[$count] + $Folder = Join-Path $Folder $Subfolder + if (($Folder.Length > 0) -and (-not (Test-Path $Folder))) { + Write-Verbose "New-Item -ItemType directory -Force -Path $Folder" + New-Item -ItemType directory -Force -Path $Folder + } + } + $DirName = $Item.DirectoryName + $Position = $DirName.IndexOf($PathAbsolute) + $PathSegment = $DirName.SubString($Position + $PathAbsolute.Length) + $NewPath = Join-Path $DestinationAbsolute $PathSegment + Copy-File -Path $Item.FullName -Destination $NewPath -Overwrite $Overwrite + $Affected = $Affected + 1 + } + Write-Verbose "[Success] $Affected items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Copy-Recurse + +#----------------------------------------------------------------------- +# Expand-File [-Path []] [-File []] +# +# Example: .\Expand-File \\source\path\file.zip \\destination\path +#----------------------------------------------------------------------- +function Expand-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$File=$(throw '-File is a required parameter.') + ) + Write-Verbose "Expand-Zip -Path $Path -File $File" + New-Path -Path $Path + [Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") + [System.IO.Compression.ZipFile]::ExtractToDirectory($File, $Path) +} +export-modulemember -function Expand-File + +#----------------------------------------------------------------------- +# Find-File [-Path []] [-File []] +# +# Example: .\Find-File \\source\path\file.zip \\destination\path +#----------------------------------------------------------------------- +function Find-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$File=$(throw '-File is a required parameter.'), + [Int32]$First=1 + ) + Write-Verbose "Find-Zip -Path $Path -File $File" + Get-Childitem -Path $Path -Include $File -Recurse| select -First $First +} +export-modulemember -function Find-File + +#----------------------------------------------------------------------- +# Get-AssemblyStrongName +# +# Example: Get-AssemblyStrongName +#----------------------------------------------------------------------- +function Get-AssemblyStrongName($assemblyPath) +{ + [System.Reflection.AssemblyName]::GetAssemblyName($assemblyPath).FullName +} +export-modulemember -function Get-AssemblyStrongName + +#----------------------------------------------------------------------- +# Get-CurrentLine +# +# Example: Get-CurrentLine +#----------------------------------------------------------------------- +function Get-CurrentLine { + $MyInvocation.ScriptLineNumber +} +export-modulemember -function Get-CurrentLine + +#----------------------------------------------------------------------- +# Get-CurrentFile +# +# Example: Get-CurrentFile +#----------------------------------------------------------------------- +function Get-CurrentFile { + $MyInvocation.ScriptName +} +export-modulemember -function Get-CurrentFile + +#----------------------------------------------------------------------- +# Get-FilesByString +# +# Example: Get-FilesByString +#----------------------------------------------------------------------- +function Get-FilesByString { + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [string]$String = $(throw '-String is a required parameter.'), + [string[]]$Include = ("*.*"), + [string[]]$Exclude = "" + ) + Write-Verbose "Get-FilesByString -Path $Path -String $String -Include $Include -Exclude $Exclude" + $Path = Set-Unc -Path $Path + + $ReturnData = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse | Select-String -pattern $String | group path | select name + + return $ReturnData +} +export-modulemember -function Get-FilesByString + +#----------------------------------------------------------------------- +# Get-SystemFolders +# +# Example: Get-SystemFolder +#----------------------------------------------------------------------- +function Get-SystemFolders +{ + param ( + ) + Write-Verbose "Get-SystemFolders" + $SpecialFolders = @{} + $names = [Environment+SpecialFolder]::GetNames([Environment+SpecialFolder]) + foreach($name in $names) + { + if($path = [Environment]::GetFolderPath($name)){ + $SpecialFolders[$name] = $path + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + } + return $SpecialFolders +} +export-modulemember -function Get-SystemFolders + +#----------------------------------------------------------------------- +# Get-SystemFolder [-Name []] +# Keys: Desktop,Programs,MyDocuments,Personal,Favorites,Startup,Recent,SendTo,StartMenu,MyMusic,MyVideos,DesktopDirectory,NetworkShortcuts,Fonts +# Templates,CommonStartMenu,CommonPrograms,CommonStartup,CommonDesktopDirectory,ApplicationData,PrinterShortcuts,LocalApplicationData,InternetCache +# Cookies,History,CommonApplicationData,Windows,System,ProgramFiles,MyPictures,UserProfile,SystemX86,ProgramFilesX86 +# CommonProgramFiles,CommonProgramFilesX86,CommonTemplates,CommonDocuments,CommonAdminTools,AdminTools,CommonMusic,CommonPictures,CommonVideos,ResourcesCDBurning +# Example: Get-SystemFolder -Name 'UserProfile' +#----------------------------------------------------------------------- +function Get-SystemFolder +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [String]$Name=$(throw '-Folder is a required parameter.') + ) + Write-Verbose "Get-SystemFolder -Folder $Folder" + if($path = [Environment]::GetFolderPath($name)){ + $Folder = $path + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + return $Folder +} +export-modulemember -function Get-SystemFolder + +#----------------------------------------------------------------------- +# Move-Path [-Path []] [-Destination []] +# [-Exclude []] +# +# Example: .\Move-Path -Path \\source\path +#----------------------------------------------------------------------- +function Move-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Destination = $(throw '-Destination is a required parameter.'), + [string]$Exclude = "" + ) + Write-Verbose "Move-Path -Path $Path -Destination $Destination" + $Path = Remove-Suffix -String $Path -Remove "\" + if (test-folder -Path $Path) + { + Remove-Path -Destination $Destination + New-Path -Destination $Destination + Copy-Recurse -Path $Path -Destination $Destination -Exclude $Exclude + Remove-Path -Destination $Path + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path moved to -Destination $Destination" + } + else + { + Write-Verbose "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Move-Path + +#----------------------------------------------------------------------- +# New-Path [-Path []] +# +# Example: .\New-Path \\source\path +#----------------------------------------------------------------------- +function New-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [bool]$Clean=$false + ) + Write-Verbose "New-Path -Path $Path" + [String]$Folder = "" + $Path = Remove-Suffix -String $Path -Remove "\" + if ($Clean) {Remove-Path -Path $Path} + if (-not (test-path $Path)) { + if (Test-Unc $Path) + { + $PathArray = $Path.Split('\') + foreach($item in $PathArray) + { + if($item.Length -gt 0) + { + if($Folder.Length -lt 1) + { + $Folder = "\\$item" + } + else + { + $Folder = "$Folder\$item" + if (-not (Test-Path $Folder)) { + New-Item -ItemType directory -Path $Folder -Force + } + } + } + } + } + else + { + New-Item -ItemType directory -Path $Path -Force + } + } +} +export-modulemember -function New-Path + +#----------------------------------------------------------------------- +# Redo-Path [-Path []] [-Destination []] +# [-Exclude []] +# +# Example: .\Redo-Path -Path \\source\path +#----------------------------------------------------------------------- +function Redo-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Destination = $(throw '-Destination is a required parameter.'), + [string]$Exclude = "" + ) + Write-Verbose "Redo-Path -Path $Path -Destination $Destination" + $Path = Remove-Suffix -String $Path -Remove "\" + Remove-Path -Destination $Path + New-Path -Destination $Path + Copy-Recurse -Path $Path -Destination $Destination -Exclude $Exclude + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path to -Destination $Destination" +} +export-modulemember -function Redo-Path + +#----------------------------------------------------------------------- +# Remove-File [-File []] +# +# Example: .\Remove-File \\source\path\file.txt +#----------------------------------------------------------------------- +function Remove-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.') + ) + Write-Verbose "Remove-File -Path $Path" + if (Test-File -Path $Path) { + Remove-Item -Path $Path -Force + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path removed." + } + else + { + Write-Verbose "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Remove-File + +#----------------------------------------------------------------------- +# Remove-Path [-Path []] +# [-Include [] [-Exclude []] +# +# Example: .\Remove-Path -Path \\source\path +#----------------------------------------------------------------------- +function Remove-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int16]$Retention = 1, + [Int32]$First = 1000 + ) + Write-Verbose "Remove-Path -Path $Path -Include $Include -Exclude $Exclude -Retention $Retention -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (test-folder -Path $Path) { + $ErrorActionPreferenceBackup = $ErrorActionPreference + $ErrorActionPreference = 'SilentlyContinue' + Get-ChildItem -Path $Path -Include $Include -Exclude $Exclude -Recurse | Where-Object {($_.PSIsContainer) -and ($_.lastwritetime -le (get-date).addDays(($Retention*-1)))} | select -First $First | Remove-Item -Force -Recurse + Remove-Item $Path -Recurse -Force + $ErrorActionPreference = $ErrorActionPreferenceBackup + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path removed." + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Remove-Path + +#----------------------------------------------------------------------- +# Remove-Subfolders [-Path []] +# [-Include [] [-Exclude []] +# +# Example: .\Remove-Subfolders -Path \\source\path +#----------------------------------------------------------------------- +function Remove-Subfolders +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Subfolder = $(throw '-Subfolder is a required parameter.'), + [Int32]$First = 1000 + ) + Write-Verbose "Remove-Subfolders -Path $Path -Subfolder $Subfolder -First $First" + $Path = Set-Unc -Path $Path + + if (test-folder -Path $Path) { + $Affected = 0 + if(Test-Unc -Path $Path) + { + $Folders=Get-ChildItem $Path -Recurse | Where-Object {($_.Name -EQ $Subfolder) -and ($_.PSIsContainer)} | select -First $First + foreach ($Folder in $Folders) { + if ($Folder.FullName) + { + [String]$FolderToRemove=Add-Suffix -String $Folder.FullName -Add "\" + Remove-Path -Path $FolderToRemove + $Affected = $Affected + 1 + } + } + } + else + { + $Remove = Add-Suffix -String $Path -Add '\' + $Remove = Add-Suffix -String $Remove -Add $Subfolder + Remove-Path -Path $Remove + $Affected = 1 + } + Write-Verbose "[Success] $Affected items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path -Subfolder $Subfolder removed." + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Remove-Subfolders + +#----------------------------------------------------------------------- +# Remove-Recurse [-Source []] [-Destination []] +# [-Include [] [-Exclude []] +# +# Example: .\Remove-Recurse \\source\path \\destination\path +#----------------------------------------------------------------------- +function Remove-Recurse +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Source is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 1000 + ) + Write-Verbose "Remove-Recurse -Path $Path -Include $Include -Exclude $Exclude" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $PathAbsolute = Convert-PathSafe -Path $Path + $Items = Get-ChildItem -Path $PathAbsolute -Recurse -Include $Include -Exclude $Exclude | where { ! $_.PSIsContainer } + $Affected = 0 + ForEach ($Item in $Items) { + Remove-File -Path $Item + $Affected = $Affected + 1 + } + Write-Verbose "[Success] $Affected items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path." + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Remove-Recurse + +#----------------------------------------------------------------------- +# Remove-Prefix [-String []] +# +# Example: .\Remove-Prefix -String Hell -Remove o +# Result: Hello +#----------------------------------------------------------------------- +function Remove-Prefix +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Remove = $(throw '-Remove is a required parameter.') + ) + Write-Verbose "Remove-Prefix -String $String -Remove $Remove" + [string]$ReturnValue = $String + if (Compare-IsFirst -String $String -BeginsWith $Remove) + { + $ReturnValue = $String.Substring($Remove.Length, $String.Length - $Remove.Length) + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -String $String already has prefix of -Remove $Remove" + } + + return $ReturnValue +} +export-modulemember -function Remove-Prefix + +#----------------------------------------------------------------------- +# Remove-Suffix [-String []] +# +# Example: .\Remove-Suffix -String Hell -Remove o +# Result: Hello +#----------------------------------------------------------------------- +function Remove-Suffix +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Remove = $(throw '-Remove is a required parameter.') + ) + Write-Verbose "Remove-Suffix -String $String -Remove $Remove" + [string]$ReturnValue = $String + if($String) + { + if (Compare-IsLast -String $String -EndsWith $Remove) + { + $ReturnValue = $ReturnValue.Substring(0, $String.Length - $Remove.Length) + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -String $String already has suffix of -Remove $Remove" + } + + return $ReturnValue +} +export-modulemember -function Remove-Suffix + +#----------------------------------------------------------------------- +# Remove-Element [-Path []] +# +# Example: .\Remove-Element -Value "" +# -XPath "//msb:None/msb:Generator" +# -Namespace @{msb = "http://schemas.microsoft.com/developer/msbuild/2003"} +# +# Called: $XMLValue = [xml](Get-Content $path) +# $Namespace = @{msb = 'http://schemas.microsoft.com/developer/msbuild/2003'} +# Remove-Element $XMLValue -XPath '//msb:None/msb:Generator' -Namespace $Namespace +# $proj.Save($path) +#----------------------------------------------------------------------- +function Remove-Element +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [xml]$Value=$(throw '-Value is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [String]$XPath=$(throw '-Value is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [String]$Namespace=$(throw '-Value is a required parameter.') + ) + Write-Verbose ".\Remove-Element -Value $Value -XPath $XPath -Namespace $Namespace -SingleNode" + $nodes = @(Select-Xml $XPath $Value -Namespace $Namespace | Foreach {$_.Node}) + if (!$nodes) { Write-Verbose "RemoveElement: XPath $XPath not found" } + if ($singleNode -and ($nodes.Count -gt 1)) { + throw "XPath $XPath found multiple nodes" + } + $Count = 0 + foreach ($node in $nodes) + { + $parentNode = $node.ParentNode + [void]$parentNode.RemoveChild($node) + $Count = $Count + 1 + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." +} +export-modulemember -function Remove-Element + +#----------------------------------------------------------------------- +# Remove-ContentsByTagContains [-Path []] +# [-Open [] [-Close []] +# +# Example: .\Remove-ContentsByTagContains \\source\path \\destination\path +# GlobalSection(TeamFoundationVersionControl) = preSolution +# EndGlobalSection +#----------------------------------------------------------------------- +function Remove-ContentsByTag +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Open = $(throw '-Open is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Close = $(throw '-Close is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Verbose "Remove-ContentsByTag -Path $Path -Open $Open -Close $Close -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $Open = $Open.Trim() + $Close = $Close.Trim() + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$OpenIndex = -1 + [Int32]$CloseIndex = -1 + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If(($OpenIndex -eq -1) -and ($CurrentLine -eq $Open)) + { + $OpenIndex = $Count + } + ElseIf(($OpenIndex -gt -1) -and ($CurrentLine -eq $Close)) + { + $CloseIndex = $Count + Break + } + } + # Evaluate search + If(($OpenIndex -gt -1) -and ($OpenIndex -lt $CloseIndex)) + { + # Match Found Remove block. + $NewContent = ($Content | Select -First $OpenIndex) + ($Content | select -Last ($Content.Length - $CloseIndex - 1)) + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Remove-ContentsByTag + +#----------------------------------------------------------------------- +# Remove-ContentsByTagContains [-Path []] +# [-Open [] [-Close []] +# +# Example: .\Remove-ContentsByTagContains \\source\path \\destination\path +# GlobalSection(TeamFoundationVersionControl) = preSolution +# EndGlobalSection +#----------------------------------------------------------------------- +function Remove-ContentsByTagContains +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Open = $(throw '-Open is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Close = $(throw '-Close is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Contains = $(throw '-Contains is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Verbose "Remove-ContentsByTagContains -Path $Path -Open $Open -Close $Close -Contains $Contains -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $Open = $Open.Trim() + $Close = $Close.Trim() + $Contains = $Contains.Trim() + + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$OpenIndex = -1 + [Int32]$ContainsIndex = -1 + [Int32]$CloseIndex = -1 + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If ($CurrentLine -like "*$Open*") + { + If($OpenIndex -gt -1) + { + # Fail: Block did not contain -Content and/or -Open was found before -Close. Reset for next open tag match. + $ContainsIndex = -1 + $CloseIndex = -1 + } + $OpenIndex = $Count + }ElseIf($OpenIndex -gt -1) + { + If($CurrentLine -like "*$Contains*") + { + $ContainsIndex = $Count + }ElseIf(($ContainsIndex -gt -1) -and ($CurrentLine -like "*$Close*")) + { + # Success, block starts with -Open, ends with -Close and includes -Contains + $CloseIndex = $Count + Break + } + } + } + # Any matches? + If(($OpenIndex -gt -1) -and ($ContainsIndex -gt $OpenIndex) -and ($CloseIndex -gt $ContainsIndex)) + { + If($CloseIndex -eq ($OpenIndex + 2)) + { + # Match Found with single element. Remove Block. + $NewContent = ($Content | Select -First $OpenIndex) + ($Content | select -Last ($Content.Length - $CloseIndex - 1)) + } + Else + { + # Match Found with multiple elements. Remove Line Only. + $NewContent = ($Content | Select -First $ContainsIndex) + ($Content | select -Last ($Content.Length - $ContainsIndex -1)) + } + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Remove-ContentsByTagContains + +#----------------------------------------------------------------------- +# Rename-File [-File []] +# +# Example: .\Rename-File -Path ($StagingZipPath + 'root.vstbak') -NewName root.vstemplate -Force +#----------------------------------------------------------------------- +function Rename-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.'), + [string]$NewName=$(throw '-NewName is a required parameter.') + ) + Write-Verbose "Rename-File -Path $Path -NewName $NewName" + if (Test-File -Path $Path) { + Rename-Item -Path $Path -NewName $NewName -Force + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path removed." + } + else + { + Write-Verbose "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Rename-File + +#----------------------------------------------------------------------- +# Set-ReadOnly [-Path []] [-ReadOnly []] +# +# Example: .\Set-ReadOnly -Path \\source\path\File.name -ReadOnly $False +#----------------------------------------------------------------------- +function Set-ReadOnly +{ + param ( + [Parameter(Mandatory = $True)] + [string]$Path = $(throw '-Path is a required parameter.'), + [bool]$ReadOnly = $True, + [string]$ErrorPreference = 'SilentlyContinue' + ) + Write-Verbose "Set-ReadOnly -Path $Path -ReadOnly $ReadOnly -ErrorPreference $ErrorPreference" + $Path = Remove-Suffix -String $Path -Remove "\" + if(test-path $Path) + { + $PathAbsolute = Convert-PathSafe -Path $Path + if (Test-Path $PathAbsolute -PathType Leaf) + { + $ErrorActionPreferenceBackup = $ErrorActionPreference + $ErrorActionPreference = $ErrorPreference + Set-ItemProperty $PathAbsolute -name IsReadOnly -value $ReadOnly -Force + $ErrorActionPreference = $ErrorActionPreferenceBackup + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path set." + } + else + { + Write-Verbose "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Set-ReadOnly + +#----------------------------------------------------------------------- +# Set-SystemFolderDrives +# +# Example: Set-SystemFolderDrives +#----------------------------------------------------------------------- +function Set-SystemFolderDrives +{ + param ( + ) + Write-Verbose "Set-SystemFolderDrives" + + $SpecialFolders = @{} + $names = [Environment+SpecialFolder]::GetNames([Environment+SpecialFolder]) + foreach($name in $names) + { + if($path = [Environment]::GetFolderPath($name)){ + $SpecialFolders[$name] = $path + New-PSDrive -Name $name -PSProvider FileSystem -Root $path + } + } + # #TBD: Find the 10 Largest Files in the Documents Folder + # gci Personal: -Recurse -Force -ea SilentlyContinue | + # Sort-Object -Property Length -Descending | + # Select-Object -First 10 | + # Format-Table -AutoSize -Wrap -Property ` + # Length,LastWriteTime,FullName + return $SpecialFolders +} +export-modulemember -function Set-SystemFolderDrives + +#----------------------------------------------------------------------- +# Test-File [-Path []] +# +# Example: .\Test-File -Path \\source\path +#----------------------------------------------------------------------- +function Test-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Test-File -Path $Path" + [bool]$ReturnValue = $false + if(Test-Path -Path $Path -PathType Leaf) + { + $ReturnValue = $true + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist or is not a File." + } + return $ReturnValue +} +export-modulemember -function Test-File + +#----------------------------------------------------------------------- +# Test-Folder [-Path []] +# +# +# Example: .\Test-Folder -Path \\source\path +#----------------------------------------------------------------------- +function Test-Folder +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Test-Folder -Path $Path" + [bool]$ReturnValue = $false + if(test-path -Path $Path -PathType Container) + { + $ReturnValue = $true + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist or is not a Folder." + } + return $ReturnValue +} +export-modulemember -function Test-Folder + +#----------------------------------------------------------------------- +# Test-PathEmpty [-Path []] +# +# Example: .\Test-PathEmpty -Path \\source\path +#----------------------------------------------------------------------- +function Test-PathEmpty +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Test-PathEmpty -Path $Path" + [bool]$ReturnValue = $false + if((Get-ChildItem $Path -force | Select-Object -First 1 | Measure-Object).Count -eq 0) + { + $ReturnValue = $true + } + else + { + Write-Verbose "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } + return $ReturnValue +} +export-modulemember -function Test-PathEmpty + +#----------------------------------------------------------------------- +# Set-Unc [-Path []] +# +# Example: .\Set-Unc -Path \\source\path +#----------------------------------------------------------------------- +function Set-Unc +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Set-Unc -Path $Path" + $Path = $Path.Trim() + $Path = Remove-Suffix -String $Path -Remove '\' + if(-not ($Path.Contains(':\') -or $Path.Contains('.\') -or (Compare-IsFirst -String $Path -BeginsWith '\'))) + { + $ReturnValue = Add-Prefix -String $Path -Add '\\' + } + else + { + $ReturnValue = $Path + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path already a UNC, drive letter, absolute or relative path." + } + return $ReturnValue +} +export-modulemember -function Set-Unc + +#----------------------------------------------------------------------- +# Test-Unc [-Path []] +# +# +# Example: .\Test-Unc -Path \\source\path +#----------------------------------------------------------------------- +function Test-Unc +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Test-Unc -Path $Path" + [bool]$ReturnValue = $false + if(($Path.Contains('\\')) -and (-not ($Path.Contains(':\')))) + { + $ReturnValue = $true + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + return $ReturnValue +} +export-modulemember -function Test-Unc + +#----------------------------------------------------------------------- +# Update-LineByContains [-Path []] +# [-Contains [] [-Close []] +# +# Example: .\Update-LineByContains -Path \\source\path -Include AssemblyInfo.cs -Contains 'AssemblyVersion(' -Line '[assembly: AssemblyVersion("5.20.07")]' +#----------------------------------------------------------------------- +function Update-LineByContains +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Contains = $(throw '-Contains is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Line = $(throw '-Line is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Verbose "Update-LineByContains -Path $Path -Contains $Contains -Line $Line -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $Contains = $Contains.Trim() + $Count = 0 + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$ContainsIndex = -1 + $Affected = 0 + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If(($ContainsIndex -eq -1) -and ($CurrentLine -eq $Contains)) + { + $ContainsIndex = $Count + Break + } + } + # Evaluate search + If($ContainsIndex -gt -1) + { + # Select before line, add -Line, select after line + $NewContent = (($Content | Select -First $ContainsIndex) + ($Line + [Environment]::NewLine) + ($Content | select -Last ($Content.Length - $ContainsIndex -1))) + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + $Affected = $Count + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-LineByContains + +#----------------------------------------------------------------------- +# Update-ContentsByTag [-Path []] +# [-Open [] [-Close []] +# +# Example: .\Update-ContentsByTag -Path $Path -Include *.sln -Open "GlobalSection(TeamFoundationVersionControl) = preSolution" -Close "EndGlobalSection" +#----------------------------------------------------------------------- +function Update-ContentsByTag +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Open = $(throw '-Open is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Close = $(throw '-Close is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Value = $(throw '-Value is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Verbose "Update-ContentsByTag -Path $Path -Open $Open -Close $Close -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + [String]$PaddingLeft = ' ' + if (Test-Path $Path) + { + $Open = $Open.Trim() + $Close = $Close.Trim() + $Affected = 0 + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$OpenIndex = -1 + [Int32]$CloseIndex = -1 + [String]$NewValue = "" + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If(($OpenIndex -eq -1) -and ($CurrentLine -like "*$Open*")) + { + $OpenIndex = $Count + } + If(($OpenIndex -gt -1) -and ($CurrentLine -like "*$Close*")) + { + $CloseIndex = $Count + Break + } + } + # Evaluate search + If(($OpenIndex -gt -1) -and ($OpenIndex -le $CloseIndex)) + { + if($OpenIndex -eq $CloseIndex) + { + # Open/Close on same line, rebuild the line with new contents + $NewValue = ($Open + $Value + $Close) + } + else + { + $NewValue = $Value + } + # Update content + $NewContent = ($Content | Select -First ($OpenIndex)) + ($PaddingLeft + $NewValue) + ($Content | select -Last ($Content.Length - $CloseIndex - 1)) + $Affected = 1 + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + } + Write-Verbose "[Success] $Affected items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-ContentsByTag + +#----------------------------------------------------------------------- +# Update-Text [-Path []] +# [-Include [] [-Exclude []] +# +# Example: .\Update-Text -Path \\source\path -Include *.cs -Old "Use gotos" -New "Point at people who use gotos" +#----------------------------------------------------------------------- +function Update-Text +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Old = $(throw '-Old is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$New = $(throw '-New is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string[]]$Include = $(throw '-Include is a required parameter.'), + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Verbose "Update-Text -Path $Path -Old $Old -New $New -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + $Count = 0 + if (Test-Path $Path) + { + $ConfigFiles=Get-Childitem $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + foreach ($Item in $ConfigFiles) + { + Set-ReadOnly -Path $Item.PSPath -ReadOnly $false + (Get-Content $Item.PSPath) | + Foreach-Object {$_.Replace($Old, $New) + } | + Set-Content $Item.PSPath -force + $Count = $Count + 1 + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-Text + +#----------------------------------------------------------------------- +# Update-TextByContains [-Path []] +# [-Contains [] [-Close []] +# +# Example: .\Update-TextByContains -Path \\source\path -Include AssemblyInfo.cs -Contains 'AssemblyVersion(' -Line '[assembly: AssemblyVersion("5.20.07")]' +#----------------------------------------------------------------------- +function Update-TextByContains +{ + param ( + [string]$Path = $(throw '-Path is a required parameter.'), + [string]$Contains = $(throw '-Contains is a required parameter.'), + [string]$Old = $(throw '-Old is a required parameter.'), + [string]$New = $(throw '-New is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Verbose "Update-TextByContains -Path $Path -Contains $Contains -Old $Old -New $New -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $Contains = $Contains.Trim() + $Count = 0 + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$FoundIndex = -1 + [String]$FoundLine = '' + $Affected = 0 + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If(($FoundIndex -eq -1) -and ($CurrentLine.ToLowerInvariant().Contains($Contains.ToLowerInvariant()))) + { + $FoundIndex = $Count + $FoundLine = $CurrentLine + Break + } + } + # Evaluate search + If($FoundIndex -gt -1) + { + # Replace text inside of line + $NewLine = $FoundLine.Replace($Old, $New) + # Select before line, add $NewLine, select after line + $NewContent = (($Content | Select -First $FoundIndex) + ($NewLine + [Environment]::NewLine) + ($Content | select -Last ($Content.Length - $FoundIndex -1))) + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + $Affected = $Count + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-TextByContains + +#----------------------------------------------------------------------- +# Update-TextByTable [-Path []] [-Replace []] +# [-Include [] [-Exclude []] +# +# Example: .\Update-TextByTable -Path \\source\path -Include *.cs +# -Replace @{'Old1' = 'New1' +# 'Old2' = 'New2' +# 'Old3' = 'New3'} +#----------------------------------------------------------------------- +function Update-TextByTable +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [hashtable]$Replace = $(throw '-Replace is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string[]]$Include = $(throw '-Include is a required parameter.'), + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Verbose "Update-Text -Path $Path -Old $Old -New $New -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + Write-Verbose "Get-Childitem $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First" + $ConfigFiles=Get-Childitem $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + Write-Verbose "ConfigFiles: $ConfigFiles" + $Count = 0 + foreach ($Item in $ConfigFiles) + { + Write-Verbose "Get-Content $Item.PSPath" + $fileLines = Get-Content $Item.PSPath + Write-Verbose "fileLines: $fileLines" + if($fileLines) + { + foreach($replaceItem in $Replace.GetEnumerator()) { + Write-Verbose "$fileLines.Replace($replaceItem.Key, $replaceItem.Value)" + $fileLines = $fileLines.Replace($replaceItem.Key, $replaceItem.Value) + } + Write-Verbose "Set-Content -Path $Item.PSPath -Value $fileLines -force" + Set-Content -Path $Item.PSPath -Value $fileLines -force + } + $Count = $Count + 1 + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-TextByTable \ No newline at end of file diff --git a/.github/scripts/cd/New-Github-Azure-Federation.ps1 b/.github/scripts/cd/New-Github-Azure-Federation.ps1 new file mode 100644 index 0000000..9965ce9 --- /dev/null +++ b/.github/scripts/cd/New-Github-Azure-Federation.ps1 @@ -0,0 +1,70 @@ +#################################################################################### +# To execute +# 1. Run Powershell as ADMINistrator +# 2. In powershell, set security polilcy for this script: +# Set-ExecutionPolicy Unrestricted -Scope Process -Force +# 3. Change directory to the script folder: +# CD C:\Scripts (wherever your script is) +# 4. In powershell, run script: +# .\New-AzureGitHubFederation.ps1 -SubscriptionId 12343dac-0e69-436a-866b-456727dd3579 -PrincipalName myco-github-devtest-001 -Organization mygithuborg -Repository mygithubrepo -Environment development +#################################################################################### + +param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [guid]$SubscriptionId = $(throw '-SubscriptionId is a required parameter.'), #12343dac-0e69-436a-866b-456727dd3579 + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$PrincipalName = $(throw '-PrincipalName is a required parameter.'), #Example: COMPANY-SUB_OR_PRODUCTLINE-github-001 + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Organization = $(throw '-Organization is a required parameter.'), #GitHub Organization Name + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Repository = $(throw '-Repository is a required parameter.'), #GitHub Repository Name + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Environment = $(throw '-Environment is a required parameter.') #GitHub Repository Environment: development, production + ) +#################################################################################### +Set-ExecutionPolicy Unrestricted -Scope Process -Force +$VerbosePreference = 'SilentlyContinue' # 'SilentlyContinue' # 'Continue' +[String]$ThisScript = $MyInvocation.MyCommand.Path +[String]$ThisDir = Split-Path $ThisScript +Set-Location $ThisDir # Ensure our location is correct, so we can use relative paths +Write-Host "*****************************" +Write-Host "*** Starting: $ThisScript On: $(Get-Date)" +Write-Host "*****************************" +#################################################################################### +# Install required modules +Install-Module Az.Accounts,Az.Resources -Scope CurrentUser -Force + +# Login to Azure +Connect-AzAccount -SubscriptionId $SubscriptionId -UseDeviceAuthentication + +# Get App Registration object (Application object) +$app = Get-AzADApplication -DisplayName $PrincipalName +if (-not $app) { + $app = New-AzADApplication -DisplayName $PrincipalName +} +Write-Host "App Registration (Client) Id: $($app.AppId)" +$clientId = $app.AppId +$appObjectId = $app.Id + +# Create Service Principal and assign role +$sp = Get-AzADServicePrincipal -DisplayName $PrincipalName +if (-not $sp) { + $sp = New-AzADServicePrincipal -ApplicationId $clientId +} +Write-Host "Service Principal Id: $($sp.Id)" +$spObjectId = $sp.Id +New-AzRoleAssignment -ObjectId $spObjectId -RoleDefinitionName Contributor -Scope "/subscriptions/$SubscriptionId" + +$tenantId = (Get-AzContext).Subscription.TenantId + +# Create new App Registration Federated Credentials for the GitHub operations +$subjectRepo = "repo:" + $Organization + "/" + $Repository + ":environment:" + $Environment +New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-repo" -Subject "$subjectRepo" +$subjectRepoMain = "repo:" + $Organization + "/" + $Repository + ":ref:refs/heads/main" +New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-main" -Subject "$subjectRepoMain" +$subjectRepoPR = "repo:" + $Organization + "/" + $Repository + ":pull_request" +New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-PR" -Subject "$subjectRepoPR" + +Write-Host "AZURE_TENANT_ID: $tenantId" +Write-Host "AZURE_SUBSCRIPTION_ID: $SubscriptionId" +Write-Host "AZURE_CLIENT_ID: $clientId" \ No newline at end of file diff --git a/.github/scripts/ci/Get-Version.ps1 b/.github/scripts/ci/Get-Version.ps1 new file mode 100644 index 0000000..bc17636 --- /dev/null +++ b/.github/scripts/ci/Get-Version.ps1 @@ -0,0 +1,94 @@ +#----------------------------------------------------------------------- +# Get-Version [-VersionToReplace ] [-Major ] [-Minor ] [-Revision ] [-Build ] [-Patch ] [-PreRelease ] [-CommitHash ] +# +# 1. Default (auto date/time for revision/build/patch): +# .\Get-Version.ps1 +# # Defaults: Major=1, Minor=0, Revision=(day of year), Build=(hour+minute), Patch=(month) +# +# 2. Set explicit major/minor (auto rest): +# .\Get-Version.ps1 -Major 2 -Minor 5 +# # Defaults: Revision=(day of year), Build=(hour+minute), Patch=(month) +# +# 3. Full explicit version (no auto): +# .\Get-Version.ps1 -Major 1 -Minor 2 -Revision 3 -Build 4 -Patch 5 +# # No defaults: all values are explicit +# +# 4. Pre-release and commit hash: +# .\Get-Version.ps1 -Major 1 -Minor 0 -Patch 1 -PreRelease -beta -CommitHash +abc123 +# # Defaults: Revision=(day of year), Build=(hour+minute) +#----------------------------------------------------------------------- + +# *** +# *** Parameters +# *** +param( + [string] $VersionToReplace = '1.0.0', + [string] $Major = '-1', + [string] $Minor = '-1', + [string] $Revision = '-1', + [string] $Build = '-1', + [string] $Patch = '-1', + [string] $PreRelease = '-1', + [string] $CommitHash = '-1' +) + +# *** +# *** Initialize +# *** + + +# *** +# *** Locals +# *** + +# *** +# *** Execute +# *** + +# Calculate version parts with defaults and protect against blank/null +function Use-ValueOrDefault { + param($Value, $Default) + if ([string]::IsNullOrWhiteSpace($Value) -or $Value -eq '-1') { return $Default } + return $Value +} + +$Major = Use-ValueOrDefault $Major (($VersionToReplace -split '\.')[0]) +if ([string]::IsNullOrWhiteSpace($Major)) { $Major = '0' } + +$Minor = Use-ValueOrDefault $Minor (($VersionToReplace -split '\.')[1]) +if ([string]::IsNullOrWhiteSpace($Minor)) { $Minor = '0' } + +$Revision = Use-ValueOrDefault $Revision ((Get-Date -UFormat '%j').ToString()) +if ([string]::IsNullOrWhiteSpace($Revision)) { $Revision = '0' } + +$Build = Use-ValueOrDefault $Build ((Get-Date -UFormat '%H%M').ToString()) +if ([string]::IsNullOrWhiteSpace($Build)) { $Build = '0' } + +$Patch = Use-ValueOrDefault $Patch ((Get-Date -UFormat '%m').ToString()) +if ([string]::IsNullOrWhiteSpace($Patch)) { $Patch = '0' } + +$PreRelease = Use-ValueOrDefault $PreRelease '' +$CommitHash = Use-ValueOrDefault $CommitHash '' + + +# Remove leading zeros for all numeric identifiers +$vMajor = [int]$Major +$vMinor = [int]$Minor +$vRevision = [int]$Revision +$vBuild = [int]$Build +$vPatch = [int]$Patch + +# Version Formats +$FileVersion = "$vMajor.$vMinor.$vRevision.$vBuild" # e.g. 1.0.0.0 +$AssemblyVersion = "$vMajor.$vMinor.0.0" +$InformationalVersion = "$vMajor.$vMinor.$vRevision$PreRelease$CommitHash" +$SemanticVersion = "$vMajor.$vMinor.$vPatch$PreRelease" + +$result = [PSCustomObject]@{ + FileVersion = $FileVersion + AssemblyVersion = $AssemblyVersion + InformationalVersion = $InformationalVersion + SemanticVersion = $SemanticVersion +} + +$result | ConvertTo-Json -Compress diff --git a/.github/scripts/ci/Set-Version.ps1 b/.github/scripts/ci/Set-Version.ps1 new file mode 100644 index 0000000..482f15c --- /dev/null +++ b/.github/scripts/ci/Set-Version.ps1 @@ -0,0 +1,101 @@ +#----------------------------------------------------------------------- +# Set-Version [-Path []] [-VersionToReplace []] [-Type []] +# +# Example: .\Set-Version -Path \\source\path -Major 1 +#----------------------------------------------------------------------- + +# *** +# *** Parameters +# *** +param +( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string] $Path=$(throw '-Path is a required parameter. i.e. $(Build.SourcesDirectory)'), + [string] $VersionToReplace='1.0.0', + [string] $Major='-1', + [string] $Minor='-1', + [string] $Revision='-1', + [string] $Build='-1', + [string] $Patch='-1', + [string] $PreRelease='-1', + [string] $CommitHash='-1' +) + +# *** +# *** Initialize +# *** +if ($IsWindows) { Set-ExecutionPolicy Unrestricted -Scope Process -Force } +$VerbosePreference = 'SilentlyContinue' #'Continue' +if ($MyInvocation.MyCommand -and $MyInvocation.MyCommand.Path) { + [String]$ThisScript = $MyInvocation.MyCommand.Path + [String]$ThisDir = Split-Path $ThisScript + [DateTime]$Now = Get-Date + Write-Debug "*****************************" + Write-Debug "*** Starting: $ThisScript on $Now" + Write-Debug "*****************************" + # Imports + Import-Module "$ThisDir/../System.psm1" +} else { + Write-Verbose "No script file context detected. Skipping module import." +} + +# *** +# *** Validate and cleanse +# *** +If($IsWindows){ + $Path = Set-Unc -Path $Path +} + +# *** +# *** Locals +# *** + +# *** +# *** Execute +# *** + + +# Calculate versions using Get-Version.ps1 (pass-through all arguments) +$ThisDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$getVersionScript = Join-Path $ThisDir 'Get-Version.ps1' +$getVersionArgs = @{ + Major = $Major + Minor = $Minor + Revision = $Revision + Build = $Build + Patch = $Patch + PreRelease = $PreRelease + CommitHash = $CommitHash + VersionToReplace = $VersionToReplace +} +$versionJson = & $getVersionScript @getVersionArgs +$versionObj = $versionJson | ConvertFrom-Json +Write-Debug "Get-Version: $($versionJson | ConvertTo-Json -Depth 10)" + +$FileVersion = $versionObj.FileVersion +$AssemblyVersion = $versionObj.AssemblyVersion +$InformationalVersion = $versionObj.InformationalVersion +$SemanticVersion = $versionObj.SemanticVersion +Write-Debug "FileVersion: $FileVersion SemanticVersion: $SemanticVersion AssemblyVersion: $AssemblyVersion InformationalVersion: $InformationalVersion" + +# *.csproj C# Project files +Update-ContentsByTag -Path $Path -Value $SemanticVersion -Open '' -Close '' -Include *.csproj +Update-ContentsByTag -Path $Path -Value $FileVersion -Open '' -Close '' -Include *.csproj +Update-ContentsByTag -Path $Path -Value $AssemblyVersion -Open '' -Close '' -Include *.csproj +Update-ContentsByTag -Path $Path -Value $InformationalVersion -Open '' -Close '' -Include *.csproj +# *.props/.targets/Directory.Build.props/targets (common for shared versioning) +Update-ContentsByTag -Path $Path -Value $SemanticVersion -Open '' -Close '' -Include *.props,*.targets,Directory.Build.props,Directory.Build.targets +Update-ContentsByTag -Path $Path -Value $SemanticVersion -Open '' -Close '' -Include *.props,*.targets,Directory.Build.props,Directory.Build.targets +# Package.json version +Update-LineByContains -Path $Path -Contains 'version' -Line """version"": ""$FileVersion""," -Include package.json +# OpenApiConfigurationOptions.cs version +Update-LineByContains -Path $Path -Contains 'Version' -Line "Version = ""$AssemblyVersion""," -Include OpenApiConfigurationOptions.cs +# *.nuspec NuGet packages +Update-ContentsByTag -Path $Path -Value $SemanticVersion -Open '' -Close '' -Include *.nuspec +# Assembly.cs C# assembly manifest +Update-LineByContains -Path $Path -Contains "FileVersion(" -Line "[assembly: FileVersion(""$FileVersion"")]" -Include AssemblyInfo.cs +Update-LineByContains -Path $Path -Contains "AssemblyVersion(" -Line "[assembly: AssemblyVersion(""$AssemblyVersion"")]" -Include AssemblyInfo.cs +# *.vsixmanifest VSIX Visual Studio Templates +Update-TextByContains -Path $Path -Contains "$null +if (-not $ghAuth) { + Write-Host "GitHub CLI not authenticated. Please login." -ForegroundColor Red + gh auth login +} + +Write-Host "Checking if repo exists..." +$repoExists = gh repo view "$Owner/$Repo" 2>$null +if (-not $repoExists) { + $createArgs = @( + 'repo', 'create', "$Owner/$Repo", + "--$vis", + '--add-readme', + '--gitignore', 'VisualStudio' + ) + if ($license) { $createArgs += @('--license', $license) } + Write-Host "DEBUG: gh $($createArgs -join ' ')" + gh @createArgs | Out-Null + Write-Host "Created repo $Owner/$Repo" + # Re-check repo existence after creation + $repoExists = gh repo view "$Owner/$Repo" 2>$null +} +else { + Write-Host "Repo $Owner/$Repo already exists. Skipping creation." +} + +# ---- 1) Allow auto-merge (repo-level toggle) +# Enables future workflows to set --auto on PRs +if ($repoExists) { + $autoMergeStatus = gh api "repos/$Owner/$Repo" | ConvertFrom-Json | Select-Object -ExpandProperty allow_auto_merge + if (-not $autoMergeStatus) { + gh api -X PATCH "repos/$Owner/$Repo" -f allow_auto_merge=true | Out-Null + Write-Host "Enabled auto-merge." + } + else { + Write-Host "Auto-merge already enabled." + } +} + +# ---- 2) Enable security & analysis: Secret Scanning + Push Protection (fixed payload) +if ($repoExists) { + $repoJson = gh api "repos/$Owner/$Repo" | ConvertFrom-Json + if ($repoJson.PSObject.Properties.Name -contains 'security_and_analysis') { + $secStatus = $repoJson.security_and_analysis + if ($secStatus.secret_scanning.status -ne "enabled" -or $secStatus.secret_scanning_push_protection.status -ne "enabled") { + $ghArgs = @( + 'api', + '-X', 'PATCH', + "repos/$Owner/$Repo", + '-f', 'secret_scanning.status=enabled', + '-f', 'secret_scanning_push_protection.status=enabled' + ) + $response = gh @ghArgs + Write-Host "Enabled secret scanning and push protection." + } + else { + Write-Host "Secret scanning and push protection already enabled." + } + } else { + Write-Host "Warning: 'security_and_analysis' property not found in repo API response. Skipping secret scanning and push protection setup." + } +} + +# ---- 3) Enable Dependabot alerts & security updates (and add version updates file) +# Alerts / Security updates are repository settings endpoints. +# (If your org enforces these by default, you can skip.) +# List/enable endpoints are under Repositories API group. +# Add dependabot.yml (version updates) if you want scheduled updates: +$dependabotYml = @" +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" +"@ + +$tmp = New-TemporaryFile +$dependabotYml | Set-Content -NoNewline -Path $tmp +if ($repoExists) { + $fileExists = gh api "/repos/$Owner/$Repo/contents/.github/dependabot.yml" 2>$null + if (-not $fileExists) { + gh api --method PUT "/repos/$Owner/$Repo/contents/.github/dependabot.yml" ` + -f message="chore: add dependabot version updates" ` + -f content="$( [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Content $tmp -Raw))) )" ` + -f branch="main" | Out-Null + Write-Host "Added dependabot.yml." + } + else { + Write-Host "dependabot.yml already exists. Skipping." + } +} +Remove-Item $tmp -Force + +# ---- 4) (Option A) Add Advanced CodeQL workflow file for full automation +# Or Advanced setup is workflow-based & fully automatable. [8](https://graphite.com/guides/github-merge-queue) +$codeqlYml = @" +name: CodeQL +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + schedule: + - cron: '0 6 * * 1' +permissions: + contents: read + security-events: write +jobs: + analyze: + runs-on: ubuntu-latest + strategy: + matrix: + language: [ 'csharp' ] + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v4 + with: + languages: '`${{ matrix.language }}' + - uses: github/codeql-action/autobuild@v3 + - uses: github/codeql-action/analyze@v3 +"@ + +$tmp = New-TemporaryFile +$codeqlYml | Set-Content -NoNewline -Path $tmp +if ($repoExists) { + $fileExists = gh api "/repos/$Owner/$Repo/contents/.github/workflows/codeql-analysis.yml" 2>$null + if (-not $fileExists) { + gh api --method PUT "/repos/$Owner/$Repo/contents/.github/workflows/codeql-analysis.yml" ` + -f message="ci: add CodeQL advanced workflow" ` + -f content="$( [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Content $tmp -Raw))) )" ` + -f branch="main" | Out-Null + Write-Host "Added CodeQL workflow." + } + else { + Write-Host "CodeQL workflow already exists. Skipping." + } +} +Remove-Item $tmp -Force + +# ---- 5) Create a new ruleset called 'main-ruleset' (modern GitHub Ruleset API) +# This is the new recommended way to enforce branch policies. +if ($repoExists) { + Write-Host "Creating 'main-ruleset' for branch 'main'..." + $rulesetBodyObj = @{ + name = "main-ruleset" + target = "branch" + enforcement = "active" + conditions = @{ + ref_name = @{ + include = @("refs/heads/main") + exclude = @() + } + } + rules = @( + # Require PR before merging + @{ type = "pull_request" }, + # Require linear history + @{ type = "required_linear_history" } + ) + # Note: To allow force-push for emergencies, add a bypass_actors array with your user/team and bypass_mode="always". + # Example: bypass_actors = @(@{ actor_id = 123456; actor_type = "User"; bypass_mode = "always" }) + } + $rulesetBody = $rulesetBodyObj | ConvertTo-Json -Compress -Depth 5 + + $existingRulesets = gh api "/repos/$Owner/$Repo/rulesets" | ConvertFrom-Json + $mainRuleset = $existingRulesets | Where-Object { $_.name -eq "main-ruleset" } + if (-not $mainRuleset) { + $response = $rulesetBody | gh api -X POST "/repos/$Owner/$Repo/rulesets" --input - -H "Accept: application/vnd.github+json" + Write-Host "'main-ruleset' created." + } + else { + Write-Host "'main-ruleset' already exists. Skipping creation." + } +} +# Ref: https://docs.github.com/en/rest/branches/rulesets?apiVersion=2022-11-28 + +# ---- 6) Create Environments: development & production +# (You can set branch policy + required reviewers) +# Create/Update Environment (PUT) + optional deployment branch policy & protection rules +# Note: required_reviewers require usernames or team slugs (max 6). [7](https://docs.github.com/en/rest/deployments/environments)[12](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments) + +# development +if ($repoExists) { + Write-Host "Checking if development environment exists..." + $devEnvResponse = gh api "repos/$Owner/$Repo/environments/development" 2>&1 + if ($devEnvResponse -match '"Not Found"' -or $devEnvResponse -match '404') { + Write-Host "Development environment does not exist. Creating..." + gh api -X PUT "repos/$Owner/$Repo/environments/development" ` + -H "Accept: application/vnd.github+json" | Out-Null + Write-Host "Development environment created." + } + else { + Write-Host "Development environment already exists. Skipping." + } +} +# Optionally add a custom branch policy pattern for dev (requires extra POST endpoint under env policies). +# See community examples for adding custom branch policies after creation. [13](https://stackoverflow.com/questions/70943164/create-environment-for-repository-using-gh) + +# NOTE: Replace reviewers with concrete users/teams via separate calls if needed. +if ($repoExists) { + Write-Host "Checking if production environment exists..." + $prodEnvResponse = gh api "repos/$Owner/$Repo/environments/production" 2>&1 + if ($prodEnvResponse -match '"Not Found"' -or $prodEnvResponse -match '404') { + Write-Host "Production environment does not exist. Creating..." + gh api -X PUT "repos/$Owner/$Repo/environments/production" ` + -H "Accept: application/vnd.github+json" | Out-Null + Write-Host "Production environment created." + } + else { + Write-Host "Production environment already exists. Skipping." + } +} +# Ref: Environments API supports branch policy and protection rules. [7](https://docs.github.com/en/rest/deployments/environments) + +Write-Host "✅ Bootstrap completed for $Owner/$Repo" diff --git a/.github/scripts/repo/New-GithubSecret.ps1 b/.github/scripts/repo/New-GithubSecret.ps1 new file mode 100644 index 0000000..fad416a --- /dev/null +++ b/.github/scripts/repo/New-GithubSecret.ps1 @@ -0,0 +1,69 @@ +# ================================ +# GitHub repo secrets (PowerShell) +# Creates secrets +# Requires: GitHub CLI (gh) + authenticated session +# ================================ +# +# Pre-requisites (auto-executed): +# - Installs GitHub CLI if not present +# - Prompts for GitHub authentication if not already authenticated +# +# Example usage (copy/paste): +# +# .\New-GithubSecret.ps1 -Owner goodtocode -Repo my-repo -Environment development -SecretName MY_SECRET -SecretValue "secret-value" +# +param( + [Parameter(Mandatory=$true)][string]$Owner, + [Parameter(Mandatory=$true)][string]$Repo, + [Parameter(Mandatory=$true)][string]$Environment, + [Parameter(Mandatory=$true)][string]$SecretName, + [Parameter(Mandatory=$true)][string]$SecretValue +) + +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + Write-Host "GitHub CLI not found. Installing via winget..." -ForegroundColor Red + winget install --id GitHub.cli -e --silent + Write-Host "GitHub CLI installed. Please restart your terminal or PowerShell session, then re-run this script." -ForegroundColor Red + exit +} + +# Check authentication +$ghAuth = gh auth status 2>$null +if (-not $ghAuth) { + Write-Host "GitHub CLI not authenticated. Please login." -ForegroundColor Red + gh auth login +} + +gh @createArgs | Out-Null + +Write-Host "Checking if repo exists..." +$repoExists = gh repo view "$Owner/$Repo" 2>$null +if (-not $repoExists) { + Write-Host "Repo $Owner/$Repo does not exist. Cannot create secrets for a non-existent repository." + exit +} else { + Write-Host "Repo $Owner/$Repo already exists. Skipping creation." +} + +# Check Environment + if ($repoExists) { + Write-Host "Checking if $Environment environment exists..." + $envExist = gh api "repos/$Owner/$Repo/environments/$Environment" 2>$null + if (-not $envExist) { + Write-Host "$Environment environment does not exist. Cannot create secrets for a non-existent environment." + } +} + +# Add environment secrets (examples) +if ($repoExists -and $envExist) { + Write-Host "Checking if $SecretName secret exists in $Environment..." + $devSecretList = gh secret list --repo "$Owner/$Repo" --env "$Environment" 2>&1 + if ($devSecretList -match '"Not Found"' -or $devSecretList -match '404' -or -not ($devSecretList | Select-String "$SecretName")) { + Write-Host "$SecretName secret does not exist in $Environment. Creating..." + gh secret set "$SecretName" --body "$SecretValue" --repo "$Owner/$Repo" --env "$Environment" + Write-Host "$SecretName secret added to $Environment." + } else { + Write-Host "$SecretName already exists in $Environment. Skipping." + } +} +Write-Host "✅ Secrets completed for $Owner/$Repo in $Environment" diff --git a/.github/workflows/gtc-semker-standalone-iac.yml b/.github/workflows/gtc-semker-standalone-iac.yml new file mode 100644 index 0000000..6aac821 --- /dev/null +++ b/.github/workflows/gtc-semker-standalone-iac.yml @@ -0,0 +1,94 @@ +name: 'Landing Zone IaC' + +on: + pull_request: + branches: + - main + paths: + - .github/workflows/gtc-agent-standalone-iac.yml + - .azure/**/*.bicep + - .azure/**/*.bicepparams + push: + branches-ignore: + - main + paths: + - .github/workflows/gtc-agent-standalone-iac.yml + - .azure/**/*.bicep + - .azure/**/*.bicepparams + workflow_dispatch: + inputs: + environment: + description: "Environment to run" + required: true + default: "development" + runcd: + description: "Run CD pipeline" + required: true + default: "false" + +env: + DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: "true" + PRODUCT_RG_NAME: "gtc-agent-dev-wus2-001-rg" + PRODUCT_RG_LOCATION: "westus2" + PRODUCT_BICEP_TEMPLATE: ".azure/templates/landingzone-standalone-web-api-sql.bicep" + PRODUCT_BICEP_PARAMETERS: ".azure/variables/landingzone-standalone-web-api-sql-dev.bicepparam" + +permissions: + id-token: write + contents: read + security-events: write + +jobs: + ci: + name: "Validate landing zone IaC" + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + environment: development + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: "Az CLI login" + uses: azure/login@v1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Create ${{ env.PRODUCT_RG_NAME }} + uses: Azure/cli@v2.1.0 + with: + inlineScript: | + az group create -n ${{ env.PRODUCT_RG_NAME }} -l ${{ env.PRODUCT_RG_LOCATION }} + + - name: Validate ${{ env.PRODUCT_RG_NAME }} + uses: Azure/cli@v2.1.0 + with: + inlineScript: | + az deployment group what-if --resource-group ${{ env.PRODUCT_RG_NAME }} --template-file ${{ env.PRODUCT_BICEP_TEMPLATE }} --parameters ${{ env.PRODUCT_BICEP_PARAMETERS }} --parameters sqlAdminUser=${{ secrets.SQL_ADMIN_USER }} sqlAdminPassword=${{ secrets.SQL_ADMIN_PASSWORD }} + + cd: + name: "Deploy landing zone IaC" + runs-on: ubuntu-latest + needs: ci + if: | + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_dispatch' && (github.event.inputs.runcd == 'true' || github.event.inputs.runcd == true)) + environment: development + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: "Az CLI login" + uses: azure/login@v1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Deploy ${{ env.PRODUCT_RG_NAME }} + uses: Azure/cli@v2.1.0 + with: + inlineScript: | + az deployment group create --resource-group ${{ env.PRODUCT_RG_NAME }} --template-file ${{ env.PRODUCT_BICEP_TEMPLATE }} --parameters ${{ env.PRODUCT_BICEP_PARAMETERS }} --parameters sqlAdminUser=${{ secrets.SQL_ADMIN_USER }} sqlAdminPassword=${{ secrets.SQL_ADMIN_PASSWORD }} + \ No newline at end of file diff --git a/.github/workflows/gtc-semker-standalone-web-api-sql.yml b/.github/workflows/gtc-semker-standalone-web-api-sql.yml new file mode 100644 index 0000000..7e3518a --- /dev/null +++ b/.github/workflows/gtc-semker-standalone-web-api-sql.yml @@ -0,0 +1,325 @@ +name: 'Web & API & SQL CI/CD' +on: + pull_request: + branches: + - main + paths: + - .github/workflows/gtc-agent-standalone-web-api-sql.yml + - src/** + push: + branches: + - main + paths: + - .github/workflows/gtc-agent-standalone-web-api-sql.yml + - src/** + workflow_dispatch: + inputs: + environment: + description: "Environment to run" + required: true + default: "development" + runcd: + description: "Run CD pipeline" + required: true + default: "false" + +permissions: + id-token: write + contents: read + security-events: write + +env: + API_ARTIFACT_OUTPUT: 'publish_output' + WEB_ARTIFACT_OUTPUT: 'publish_web_output' + API_ARTIFACT_NAME: 'api-artifact' + WEB_ARTIFACT_NAME: 'web-artifact' + MIGRATION_ARTIFACT_NAME: 'ef-migrations' + MIGRATION_ARTIFACT_PATH: 'ef-migrations.sql' + MIGRATION_ARTIFACT_OUTPUT_PATH: './' + +jobs: + ci: + name: "Web, API and SQL CI" + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + environment: development + strategy: + matrix: + DOTNET_VERSION: ["10.x"] + env: + SCRIPTS_PATH: "./.github/scripts" + SRC_PATH: "./src" + SRC_SLN: "SemanticKernelBlazor.sln" + API_PATH: "Presentation.WebApi" + API_PROJECT: "Presentation.WebApi.csproj" + TEST_PATH: "Tests.Specs.Integration" + TEST_PROJECT: "Tests.Specs.Integration.csproj" + WEB_PATH: "Presentation.Blazor" + WEB_PROJECT: "Presentation.Blazor.csproj" + INFRA_PATH: 'Infrastructure.SqlServer' + INFRA_PROJECT: 'Infrastructure.SqlServer.csproj' + INFRA_DBCONTEXT: 'SemanticKernelContext' + RUNTIME_ENV: "Development" + CONFIGURATION: "Release" + VERSION_MAJOR: '2' + VERSION_MINOR: '0' + + steps: + - name: checkout + uses: actions/checkout@v3 + + - name: dotnet version ${{ matrix.DOTNET_VERSION }} + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ matrix.DOTNET_VERSION }} + + - name: Get Semantic Version + id: get_version + run: | + $json = & "${{ env.SCRIPTS_PATH }}/ci/Get-Version.ps1" -Major ${{ env.VERSION_MAJOR }} -Minor ${{ env.VERSION_MINOR }} -Patch $env:GITHUB_RUN_NUMBER + $versionObj = ($json | ConvertFrom-Json) + echo "SEMANTIC_VERSION=$($versionObj.SemanticVersion)" >> $env:GITHUB_OUTPUT + echo "FILE_VERSION=$($versionObj.FileVersion)" >> $env:GITHUB_OUTPUT + echo "ASSEMBLY_VERSION=$($versionObj.AssemblyVersion)" >> $env:GITHUB_OUTPUT + echo "INFORMATIONAL_VERSION=$($versionObj.InformationalVersion)" >> $env:GITHUB_OUTPUT + shell: pwsh + + - name: Set-Version.ps1 + run: | + $version = ${{ env.SCRIPTS_PATH }}/ci/Set-Version.ps1 -Path ${{ env.SRC_PATH }} -VersionToReplace 1.0.0 -Major ${{ env.VERSION_MAJOR }} -Minor ${{ env.VERSION_MINOR }} -Patch $env:GITHUB_RUN_NUMBER + echo $version + echo "VERSION=$version" >> $GITHUB_ENV + shell: pwsh + + - name: pipeline environment configuration + run: | + echo "ASPNETCORE_ENVIRONMENT=${{ env.RUNTIME_ENV }}" >> $GITHUB_ENV + echo "OpenAI:ApiKey=${{ secrets.OPENAI_APIKEY }}" >> $GITHUB_ENV + shell: pwsh + + - name: App Settings Variable Substitution + uses: microsoft/variable-substitution@v1 + with: + files: '${{ env.SRC_PATH }}/${{ env.API_PATH }}/appsettings.json, ${{ env.SRC_PATH }}/${{ env.API_PATH }}/appsettings.${{ env.RUNTIME_ENV }}.json, ${{ env.SRC_PATH }}/${{ env.TEST_PATH }}/appsettings.test.json' + env: + OpenAI.ApiKey: ${{ secrets.OPENAI_APIKEY }} + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: csharp + + - name: Build + run: | + dotnet build ${{ env.SRC_PATH }}/${{ env.SRC_SLN }} --configuration ${{ env.CONFIGURATION }} + shell: pwsh + + - name: Install dotnet-ef + run: | + dotnet tool install --global dotnet-ef + echo Home: $env:HOME + echo "GITHUB_PATH=$env:HOME/.dotnet/tools" >> $GITHUB_ENV + shell: pwsh + + - name: Check for uncommitted EF Core model changes (diagnostic) + run: | + Write-Host "Working Directory: $(Get-Location)" + $tempDir = "${{ env.SRC_PATH }}/${{ env.INFRA_PATH }}/.temp-migrations" + if (Test-Path $tempDir) { Remove-Item -Recurse -Force $tempDir } + Write-Host "Running command: dotnet ef migrations add TempCheck --project ${{ env.SRC_PATH }}/${{ env.INFRA_PATH }}/${{ env.INFRA_PROJECT }} --startup-project ${{ env.SRC_PATH }}/${{ env.API_PATH }}/${{ env.API_PROJECT }} --context ${{ env.INFRA_DBCONTEXT }} --output-dir $tempDir" + dotnet ef migrations add TempCheck --project ${{ env.SRC_PATH }}/${{ env.INFRA_PATH }}/${{ env.INFRA_PROJECT }} --startup-project ${{ env.SRC_PATH }}/${{ env.API_PATH }}/${{ env.API_PROJECT }} --context ${{ env.INFRA_DBCONTEXT }} --output-dir $tempDir --configuration ${{ env.CONFIGURATION }} --no-build + if ($LASTEXITCODE -ne 0) { + Write-Host "::error::dotnet ef migrations add failed." + exit 1 + } + if (Test-Path $tempDir) { + $files = Get-ChildItem -Path $tempDir + $files | ForEach-Object { Write-Host $_.FullName } + if ($files) { + Write-Host "::error::Uncommitted model changes detected! Please run 'dotnet ef migrations add' locally and commit the migration files." + Remove-Item -Recurse -Force $tempDir + exit 1 + } + Remove-Item -Recurse -Force $tempDir + } + shell: pwsh + + - name: Create ${{ env.INFRA_DBCONTEXT }} SQL Script + run: | + dotnet ef migrations script --project ${{ env.SRC_PATH }}/${{ env.INFRA_PATH }}/${{ env.INFRA_PROJECT }} --startup-project ${{ env.SRC_PATH }}/${{ env.API_PATH }}/${{ env.API_PROJECT }} --context ${{ env.INFRA_DBCONTEXT }} --output ${{ env.MIGRATION_ARTIFACT_PATH }} --idempotent --configuration ${{ env.CONFIGURATION }} --no-build + shell: pwsh + + - name: Upload migration script + uses: actions/upload-artifact@v4 + with: + name: ${{ env.MIGRATION_ARTIFACT_NAME }} + path: ${{ env.MIGRATION_ARTIFACT_PATH }} + + - name: Test + run: | + mkdir -p TestResults-${{ matrix.DOTNET_VERSION }} + dotnet test ${{ env.SRC_PATH }}/${{ env.TEST_PATH }}/${{ env.TEST_PROJECT }} --configuration ${{ env.CONFIGURATION }} --results-directory TestResults-${{ matrix.DOTNET_VERSION }} --collect:"Code Coverage" --verbosity normal + shell: pwsh + + - name: Upload test results + uses: actions/upload-artifact@v4 + with: + name: dotnet-results-${{ matrix.DOTNET_VERSION }} + path: TestResults-${{ matrix.DOTNET_VERSION }} + if: ${{ always() }} + + - name: Pack Web API artifact + run: | + dotnet publish ${{ env.SRC_PATH }}/${{ env.API_PATH }}/${{ env.API_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-build --output publish_output + shell: pwsh + + - name: Upload Web API artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.API_ARTIFACT_NAME }} + path: ${{ env.API_ARTIFACT_OUTPUT }}/** + + - name: Pack Blazor artifact + run: | + dotnet publish ${{ env.SRC_PATH }}/${{ env.WEB_PATH }}/${{ env.WEB_PROJECT }} --configuration ${{ env.CONFIGURATION }} --no-build --output publish_web_output + shell: pwsh + + - name: Upload Blazor artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.WEB_ARTIFACT_NAME }} + path: ${{ env.WEB_ARTIFACT_OUTPUT }}/** + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + if: ${{ !github.event.repository.private }} + + - name: Run Coverage Script + run: | + pwsh ${{ env.SRC_PATH }}/Get-CodeCoverage.ps1 ` + -TestProjectFilter '${{ env.TEST_PROJECT }}' ` + -ProdPackagesOnly + shell: pwsh + + - name: Upload Coverage Report + uses: actions/upload-artifact@v4 + with: + name: coverage-report-${{ matrix.DOTNET_VERSION }} + path: ${{ env.SRC_PATH }}/TestResults/Reports/**/index.html + if: ${{ always() }} + + cd: + name: "Web, API and SQL CD" + runs-on: ubuntu-latest + needs: ci + if: | + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'workflow_dispatch' && (github.event.inputs.runcd == 'true' || github.event.inputs.runcd == true)) + environment: development + strategy: + matrix: + DOTNET_VERSION: ["10.x"] + env: + APPI_NAME: 'semker-dev-wus2-001-appi' + AZURE_APIAPP_NAME: semker-dev-wus2-001-api + AZURE_WEBAPP_PACKAGE_PATH: '.' + AZURE_WEBAPP_NAME: semker-dev-wus2-001-web + AZURE_RG_NAME: 'gtc-agent-dev-wus2-001-rg' + SQL_NAME: 'semker-dev-wus2-001-sql' + SQLDB_NAME: 'semker-dev-wus2-001-sqldb' + RUNTIME_ENV: "Development" + + steps: + - name: Download Web API artifact + uses: actions/download-artifact@v4 + with: + name: ${{ env.API_ARTIFACT_NAME }} + path: ${{ env.API_ARTIFACT_OUTPUT }} + + - name: Download Web Artifact + uses: actions/download-artifact@v4 + with: + name: ${{ env.WEB_ARTIFACT_NAME }} + path: ${{ env.WEB_ARTIFACT_OUTPUT }} + + - name: Download migration script + uses: actions/download-artifact@v4 + with: + name: ${{ env.MIGRATION_ARTIFACT_NAME }} + path: ${{ env.MIGRATION_ARTIFACT_OUTPUT_PATH }} + + + - name: az login + uses: azure/login@v1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Get SQL connection string + id: get_sql_conn + run: | + $TEMP_STR=az sql db show-connection-string --client ado.net --server ${{ env.SQL_NAME }} --name ${{ env.SQLDB_NAME }} -o tsv + $TEMP_STR=$TEMP_STR.replace("", "${{ secrets.SQL_ADMIN_USER }}") + $TEMP_STR=$TEMP_STR.replace("", "${{ secrets.SQL_ADMIN_PASSWORD }}") + echo "CONN_STR=$TEMP_STR" >> $env:GITHUB_OUTPUT + shell: pwsh + + - name: Deploy SQL script + uses: azure/sql-action@v2 + with: + connection-string: ${{ steps.get_sql_conn.outputs.CONN_STR }} + path: ${{ env.MIGRATION_ARTIFACT_PATH }} + + - name: Deploy Web API to Azure App Service + uses: azure/webapps-deploy@v2 + with: + app-name: ${{ env.AZURE_APIAPP_NAME }} + package: ${{ env.API_ARTIFACT_OUTPUT }} + + - name: ${{ env.AZURE_APIAPP_NAME }} app settings + run: | + az config set extension.use_dynamic_install=yes_without_prompt + $TEMP_JSON = az monitor app-insights component show -g ${{ env.AZURE_RG_NAME }} --app ${{ env.APPI_NAME }} | ConvertFrom-Json + $INSTR_KEY = $TEMP_JSON.instrumentationKey + $CONN_STR = $TEMP_JSON.connectionString + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_APIAPP_NAME }} --settings APPINSIGHTS_INSTRUMENTATIONKEY=$INSTR_KEY + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_APIAPP_NAME }} --settings APPLICATIONINSIGHTS_CONNECTION_STRING=$CONN_STR + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_APIAPP_NAME }} --settings ASPNETCORE_ENVIRONMENT=${{ env.RUNTIME_ENV }} + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_APIAPP_NAME }} --settings EntraExternalId:TenantId=${{ secrets.EEID_TENANT_ID }} + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_APIAPP_NAME }} --settings EntraExternalId:ClientId=${{ secrets.API_CLIENT_ID }} + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_APIAPP_NAME }} --settings OpenAI:ApiKey=${{ secrets.OPENAI_APIKEY }} + shell: pwsh + + - name: ${{ env.AZURE_APIAPP_NAME }} connection strings + run: | + az webapp config connection-string set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_APIAPP_NAME }} -t SQLServer --settings DefaultConnection="${{ steps.get_sql_conn.outputs.CONN_STR }}" + shell: pwsh + + - name: Deploy ${{ env.AZURE_WEBAPP_NAME }} + uses: azure/webapps-deploy@v2 + with: + app-name: ${{ env.AZURE_WEBAPP_NAME }} + package: ${{ env.WEB_ARTIFACT_OUTPUT }} + + - name: ${{ env.AZURE_WEBAPP_NAME }} app settings + run: | + az config set extension.use_dynamic_install=yes_without_prompt + $TEMP_JSON = az monitor app-insights component show -g ${{ env.AZURE_RG_NAME }} --app ${{ env.APPI_NAME }} | ConvertFrom-Json + $INSTR_KEY = $TEMP_JSON.instrumentationKey + $CONN_STR = $TEMP_JSON.connectionString + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_WEBAPP_NAME }} --settings APPINSIGHTS_INSTRUMENTATIONKEY=$INSTR_KEY + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_WEBAPP_NAME }} --settings APPLICATIONINSIGHTS_CONNECTION_STRING=$CONN_STR + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_WEBAPP_NAME }} --settings ASPNETCORE_ENVIRONMENT=${{ env.RUNTIME_ENV }} + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_WEBAPP_NAME }} --settings EntraExternalId:TenantId=${{ secrets.EEID_TENANT_ID }} + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_WEBAPP_NAME }} --settings EntraExternalId:ClientId=${{ secrets.WEB_CLIENT_ID }} + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_WEBAPP_NAME }} --settings EntraExternalId:ClientSecret=${{ secrets.WEB_CLIENT_SECRET }} + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_WEBAPP_NAME }} --settings BackendApi:BaseUrl="https://${{ env.AZURE_APIAPP_NAME }}.azurewebsites.net" + az webapp config appsettings set -g ${{ env.AZURE_RG_NAME }} -n ${{ env.AZURE_WEBAPP_NAME }} --settings BackendApi:ClientId=${{ secrets.API_CLIENT_ID }} + shell: pwsh + + - name: Swap to production slot + run: | + az webapp deployment slot swap --resource-group ${{ env.AZURE_RG_NAME }} --name ${{ env.AZURE_APIAPP_NAME }} --slot staging --target-slot production + echo "Swap finished. App Service Application URL: https://$(az webapp show --resource-group ${{ env.AZURE_RG_NAME }} --name ${{ env.AZURE_APIAPP_NAME }} --query hostNames[0] -o tsv)" + if: ${{ false }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index ce89292..ad9e126 100644 --- a/.gitignore +++ b/.gitignore @@ -3,13 +3,18 @@ ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +# Documentation +*.Presentation.WebApi.xml +[Ss]wagger/ +swagger.json +.config/ + # User-specific files *.rsuser *.suo *.user *.userosscache *.sln.docstates -*.env # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -27,19 +32,12 @@ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ -[Aa][Rr][Mm]64[Ee][Cc]/ bld/ +[Bb]in/ [Oo]bj/ -[Oo]ut/ [Ll]og/ [Ll]ogs/ -# Build results on 'Bin' directories -**/[Bb]in/* -# Uncomment if you have tasks that rely on *.refresh files to move binaries -# (https://github.com/github/gitignore/pull/3736) -#!**/[Bb]in/*.refresh - # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot @@ -51,16 +49,12 @@ Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* -*.trx # NUnit *.VisualState.xml TestResult.xml nunit-*.xml -# Approval Tests result files -*.received.* - # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ @@ -87,7 +81,6 @@ StyleCopReport.xml *.ilk *.meta *.obj -*.idb *.iobj *.pch *.pdb @@ -95,8 +88,6 @@ StyleCopReport.xml *.pgc *.pgd *.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp *.sbr *.tlb *.tli @@ -168,7 +159,6 @@ coverage*.info # NCrunch _NCrunch_* -.NCrunch_* .*crunch*.local.xml nCrunchTemp_* @@ -330,22 +320,22 @@ node_modules/ _Pvt_Extensions # Paket dependency manager -**/.paket/paket.exe +.paket/paket.exe paket-files/ # FAKE - F# Make -**/.fake/ +.fake/ # CodeRush personal settings -**/.cr/personal +.cr/personal # Python Tools for Visual Studio (PTVS) -**/__pycache__/ +__pycache__/ *.pyc # Cake - Uncomment if you are using it -#tools/** -#!tools/packages.config +# tools/** +# !tools/packages.config # Tabs Studio *.tss @@ -367,19 +357,15 @@ ASALocalRun/ # MSBuild Binary and Structured Log *.binlog -MSBuild_Logs/ - -# AWS SAM Build and Temporary Artifacts folder -.aws-sam # NVidia Nsight GPU debugger configuration file *.nvuser # MFractors (Xamarin productivity tool) working folder -**/.mfractor/ +.mfractor/ # Local History for Visual Studio -**/.localhistory/ +.localhistory/ # Visual Studio History (VSHistory) files .vshistory/ @@ -391,7 +377,7 @@ healthchecksdb MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder -**/.ionide/ +.ionide/ # Fody - auto-generated XML schema FodyWeavers.xsd @@ -402,17 +388,17 @@ FodyWeavers.xsd !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json -!.vscode/*.code-snippets +*.code-workspace # Local History for Visual Studio Code .history/ -# Built Visual Studio Code Extensions -*.vsix - # Windows Installer files from build outputs *.cab *.msi *.msix *.msm *.msp + +# JetBrains Rider +*.sln.iml diff --git a/LICENSE b/LICENSE index 7537085..d8c3074 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Robert J. Good +Copyright (c) Robert J. Good Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 3af2aca..8f14d5f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,432 @@ -# agent-quick-start -Microsoft Agent Framework quick-start for Web API and Blazor. +# Semantic Kernel C# Quick-Start +**Azure Bicep Infrastucture as Standalone Landing Zone** + +[![Landing Zone IaC](https://github.com/goodtocode/agent-framework-quick-start/actions/workflows/gtc-agent-standalone-iac.yml/badge.svg)](https://github.com/goodtocode/agent-framework-quick-start/actions/workflows/gtc-agent-standalone-iac.yml) + +**Clean Architecture C# Blazor Microapp and Web API Microservice** + +[![Web & API & SQL CI/CD](https://github.com/goodtocode/agent-framework-quick-start/actions/workflows/gtc-agent-standalone-web-api-sql.yml/badge.svg)](https://github.com/goodtocode/agent-framework-quick-start/actions/workflows/gtc-agent-standalone-web-api-sql.yml) + +Microsoft Agent Framework Quick-start is a .NET Web API CRUD Microservice solution with Blazor Copilot-ish Chat client that demonstrates the most basic use cases of the Microsoft Semantic Kernel in a Clean Architecture C# Microservice. This microservice allows you to persist the following Azure Open AI services to SQL Server, so you can replay messages and maintain history of your interaction with AI. + +![Microsoft Agent Framework Quick-start Blazor](./docs/AgentFramework-Quick-start-Blazor-Side-by-Side.png) + +Semantic Kernel is an SDK that integrates Large Language Models (LLMs) like OpenAI, Azure OpenAI, and Hugging Face with conventional programming languages like C#, Python, and Java. Semantic Kernel allows developers to define plugins that can be chained together in just a few lines of code. + +[Introduction to Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel/overview/) + +[Getting Started with Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel/get-started/quick-start-guide?pivots=programming-language-csharp) + +This microservice supports: +* Chat Completions: Generate responses based on user input, making it useful for chatbots and virtual assistants. +* Text to Speech: Convert text into natural-sounding speech, enhancing user experiences. +* Whisper (Text to Speech): Convert spoken language into text, useful for transcription and voice recognition. +* Image to Text: Generate descriptions of an image. +* Text to Image: Create an image based on a description prompt of the desired imagery. + +Upcoming relases will support more Semantic Kernel and Azure Open AI functionality such as: +* Embeddings: Create vector representations of text, which can be used for semantic search and similarity matching. +* Function Calling: Integrate custom functions into your AI models, allowing the model to call external APIs or perform specific tasks based on the context of the conversation. +* Content Filtering: Automatically filter out inappropriate or harmful content from generated responses. +* Fine-Tuning: Train models on your specific data to better align with your use cases and improve performance. +* Assistants: Create and manage virtual assistants that can handle complex tasks and interactions. +* Semantic Search: Perform searches based on the meaning of the text rather than just keywords, improving search relevance. + +# Quick-Start Steps +To get started, follow the steps below: +1. Clone this repository + ``` + git clone https://github.com/goodtocode/agent-framework-quick-start.git + ``` +2. Install Prerequisites + ``` + winget install Microsoft.DotNet.SDK.10 --silent + ``` + ``` + dotnet tool install --global dotnet-ef + ``` +3. **Configure Entra External ID (EEID) Authentication** + + **IMPORTANT:** This product depends on Entra External ID. This means you must have an EEID tenant or create one at https://portal.azure.com. An empty tenant will work. Please remember to create a break-glass user as a backup in cse you get locked out. + + Configure EEID authentication using one of the following methods: + 1. If you do not have app registrations, run `New-EntraAppRegistrations.ps1` to create both Web and API app registrations and set all user-secrets (admin consent required). + ``` + pwsh -File ./.azure/scripts/entra/New-EntraAppRegistrations.ps1 -EntraInstanceUrl "https://your-tenant-name.ciamlogin.com" -TenantId "" -WebAppRegistrationName "myproduct-web-dev-001" -ApiAppRegistrationName "myproduct-api-dev-001" -WebProjectPath "../../src/Presentation.Blazor" -ApiProjectPath "../../src/Presentation.WebApi" + ``` + + 2. **OR** Run `Set-WebAppUserSecrets.ps1` and `Set-ApiAppUserSecrets.ps1` to set user-secrets from your account. + ``` + pwsh -File ./.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 -TenantId "" -ApiAppRegistrationName "myproduct-api-dev-001" -ApiProjectPath "../../src/Presentation.WebApi" + ``` + ``` + pwsh -File ./.azure/scripts/entra/Set-WebAppUserSecrets.ps1 -TenantId "" -WebAppRegistrationName "myproduct-web-dev-001" -WebProjectPath "../../src/Presentation.Blazor" + ``` + + 3. **OR** Manually set the required values using `dotnet user-secrets set` (or appsettings.local.json, not recommended for secrets). + ``` + cd src/Presentation.WebApi + dotnet user-secrets init + dotnet user-secrets set "EntraExternalId:Instance" "https://your-tenant-name.ciamlogin.com" + dotnet user-secrets set "EntraExternalId:TenantId" "" + dotnet user-secrets set "EntraExternalId:ClientId" "" + dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" + ``` + + ``` + cd src/Presentation.WebApi + dotnet user-secrets init + dotnet user-secrets set "BackEndApi:ClientId" "" + dotnet user-secrets set "EntraExternalId:Instance" "https://your-tenant-name.ciamlogin.com" + dotnet user-secrets set "EntraExternalId:TenantId" "" + dotnet user-secrets set "EntraExternalId:ClientId" "" + dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" + dotnet user-secrets set "EntraExternalId:ClientSecret" "" + + cd ../../ + ``` + + See the **Authentication** section below for details and examples. + +4. Add your Open AI or Azure Open AI key to configuration (via *dotnet user-secrets set* command) + ``` + cd src/Presentation.WebApi + dotnet user-secrets set "OpenAI:ApiKey" "YOUR_API_KEY" + ``` + ``` + cd ../Tests.Specs.Integration + dotnet user-secrets set "OpenAI:ApiKey" "YOUR_API_KEY" + ``` +5. Create your SQL Server database & schema (via *dotnet ef* command) + ``` + cd ../../ + dotnet ef database update --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context SemanticKernelContext --connection "Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=SemanticKernel;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30" + ``` +6. Run Tests (Tests.Specs.Integration) + ``` + cd src/Tests.Specs.Integration + dotnet test + ``` +7. Run Blazor Web Chat Client (Presentation.Blazor) and Web API (Presentation.WebApi) + ``` + cd ../ + dotnet run --project Presentation.WebApi/Presentation.WebApi.csproj + dotnet run --project Presentation.Blazor/Presentation.Blazor.csproj + ``` + **Note:** By default, Presentation.WebApi runs on https://localhost:6075 and Presentation.Blazor runs on https://localhost:7175 unless configured otherwise. + +# Install Prerequisites +You will need the following tools: +## Visual Studio +[Visual Studio Workload IDs](https://learn.microsoft.com/en-us/visualstudio/install/workload-component-id-vs-community?view=vs-2022&preserve-view=true) +``` +winget install --id Microsoft.VisualStudio.2022.Community --override "--quiet --add Microsoft.Visualstudio.Workload.Azure --add Microsoft.VisualStudio.Workload.Data --add Microsoft.VisualStudio.Workload.ManagedDesktop --add Microsoft.VisualStudio.Workload.NetWeb" +``` + +## .NET SDK +``` +winget install Microsoft.DotNet.SDK.10 --silent +``` + +## dotnet ef cli +Install +``` +dotnet tool install --global dotnet-ef +``` + +## SQL Server +Visual Studio installs SQL Express. If you want full-featured SQL Server, install the SQL Server Developer Edition or above. + +[SQL Server Developer Edition or above](https://www.microsoft.com/en-us/sql-server/sql-server-downloads) + + + +# Authentication (Entra External ID) + +This project uses Entra External ID (EEID) for authentication. You only need to complete ONE of the following methods (they are alternatives, not cumulative). **The preferred approach is to use the script to create both app registrations, as this will configure all required claims, roles, and scopes custom to this quick-start.** + +**1. Create both app registrations and configure automatically (recommended for most users):** + +If you do not have app registrations, run the script below to create both Web and API app registrations and set all user-secrets (admin consent required): + +``` +pwsh -File ./.azure/scripts/entra/New-EntraAppRegistrations.ps1 -EntraInstanceUrl "https://your-tenant-name.ciamlogin.com" -TenantId "" -WebAppRegistrationName "myproduct-web-dev-001" -ApiAppRegistrationName "myproduct-api-dev-001" -WebProjectPath "../../src/Presentation.Blazor" -ApiProjectPath "../../src/Presentation.WebApi" +``` + +You will be prompted to grant admin consent in the Azure Portal twice (once for each app registration: Web and API). Look for a console message like this for each app: + +``` +ACTION REQUIRED: Grant admin consent for Web app permissions in the Azure Portal: +Open the following URL in your browser: +https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Permissions/appId//isMSAApp~/false +Then click 'Grant admin consent for ...' in the API permissions blade. +``` +The script will output a summary table with all relevant IDs (TenantId, Instance, AppIds, ObjectIds, Redirect URIs, etc.) for your reference. + +**2. OR: Use existing app registrations and configure .NET secrets:** + +If you already have app registrations, run the following scripts to set user-secrets from your account: + +``` +pwsh -File ./.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 -TenantId "" -ApiAppRegistrationName "myproduct-api-dev-001" -ApiProjectPath "../../src/Presentation.WebApi" +``` +``` +pwsh -File ./.azure/scripts/entra/Set-WebAppUserSecrets.ps1 -TenantId "" -WebAppRegistrationName "myproduct-web-dev-001" -WebProjectPath "../../src/Presentation.Blazor" +``` + +**3. OR: Configure everything manually:** + +Set the required values using `dotnet user-secrets set` (or appsettings.local.json, not recommended for secrets): + +``` +cd src/Presentation.WebApi +dotnet user-secrets init +dotnet user-secrets set "EntraExternalId:Instance" "https://your-tenant-name.ciamlogin.com" +dotnet user-secrets set "EntraExternalId:TenantId" "" +dotnet user-secrets set "EntraExternalId:ClientId" "" +dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" +``` + +``` +cd src/Presentation.WebApi +dotnet user-secrets init +dotnet user-secrets set "BackEndApi:ClientId" "" +dotnet user-secrets set "EntraExternalId:Instance" "https://your-tenant-name.ciamlogin.com" +dotnet user-secrets set "EntraExternalId:TenantId" "" +dotnet user-secrets set "EntraExternalId:ClientId" "" +dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" +dotnet user-secrets set "EntraExternalId:ClientSecret" "" + +cd ../../ +``` + +**EEID configuration values include:** + - Entra Instance URL + - Tenant ID + - Client IDs for Web and API + - Client secrets (if applicable) + - Redirect URIs + - API scopes + +**Note:** +- You must use the correct Entra instance and tenant for your environment. +- The app registration names and GUIDs in the script are examples—replace them with your own values. +- For more details on Entra External ID, see [Microsoft Entra External ID documentation](https://learn.microsoft.com/en-us/azure/active-directory/external-identities/). + +# Configure API Key and Connection String +Follow these steps to get your development environment set up: + +## ASPNETCORE_ENVIRONMENT set to "Local" in launchsettings.json +1. This project uses the following ASPNETCORE_ENVIRONMENT to set configuration profile +- Debugging uses Properties/launchSettings.json +- launchSettings.json is set to Local, which relies on appsettings.Local.json +2. As a standard practice, set ASPNETCORE_ENVIRONMENT entry in your Enviornment Variables and restart Visual Studio + ``` + Set-Item -Path Env:ASPNETCORE_ENVIRONMENT -Value "Development" + Get-Childitem env: + ``` + +## Setup Azure Open AI or Open AI configuration +**Important:** Do this for both Presentation.WebApi and Tests.Specs.Integration +### Azure Open AI +``` +cd src/Presentation.WebApi +dotnet user-secrets set "AzureOpenAI:ChatDeploymentName" "gpt-4" +dotnet user-secrets set "AzureOpenAI:Endpoint" "https://YOUR_ENDPOINT.openai.azure.com/" +dotnet user-secrets set "AzureOpenAI:ApiKey" "YOUR_API_KEY" +cd ../Tests.Specs.Integration +dotnet user-secrets set "AzureOpenAI:ChatDeploymentName" "gpt-4" +dotnet user-secrets set "AzureOpenAI:Endpoint" "https://YOUR_ENDPOINT.openai.azure.com/" +dotnet user-secrets set "AzureOpenAI:ApiKey" "YOUR_API_KEY" +``` +Alternately you can set in Environment variables +``` +AzureOpenAI__ChatDeploymentName +AzureOpenAI__Endpoint +AzureOpenAI__ApiKey +``` + +### Open AI +Set API Key in both Presentation.WebApi and Tests.Specs.Integration projects +``` +cd src/Presentation.WebApi +dotnet user-secrets set "OpenAI:ApiKey" "YOUR_API_KEY" +cd ../Tests.Specs.Integration +dotnet user-secrets set "OpenAI:ApiKey" "YOUR_API_KEY" +``` +Alternately you can set in Environment variables +``` +OpenAI__ChatModelId +OpenAI__ApiKey +``` + +## Setup your SQL Server connection string +``` +dotnet user-secrets init +dotnet user-secrets set "ConnectionStrings:DefaultConnection" "YOUR_SQL_CONNECTION_STRING" +``` +# Create SQL Server Database +## dotnet ef migrate steps + +1. Open Windows Terminal in Powershell or Cmd mode +2. cd to root of repository +3. (Optional) If you have an existing database, scaffold current entities into your project + + ``` + dotnet ef dbcontext scaffold "Data Source=localhost;Initial Catalog=SemanticKernel;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;" Microsoft.EntityFrameworkCore.SqlServer -t WeatherForecastView -c WeatherChannelContext -f -o WebApi + ``` + +4. Create an initial migration + ``` + dotnet ef migrations add InitialCreate --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context SemanticKernelContext + ``` + +5. Develop new entities and configurations +6. When ready to deploy new entities and configurations + + ``` + dotnet ef database update --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context SemanticKernelContext --connection "Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=SemanticKernel;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30" + ``` +7. When an entity changes, is created or deleted, create a new migration. Suggest doing this each new version. + ``` + dotnet ef migrations add v1.1.1 --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context SemanticKernelContext + ``` + +# Running the Application +## Launch the backend +Right-click Presentation.WebApi and select Set as Default Project +``` +dotnet run --project src/Presentation.WebApi/Presentation.WebApi.csproj +``` + +## Open http://localhost:7777/swagger/index.html +Open Microsoft Edge or modern browser +Navigate to: http://localhost:7777/swagger/index.html in your browser to the Swagger API Interface + + +# DevOps Configuration for Azure IaC and CI/CD +## GitHub Actions (.github folder) +The GitHub action will automatically run upon commit to a repo. The triggers are set based on changes (PRs/Merges) to the main branch. + +### Azure Federation to GitHub Actions +gtc-rg-AgentFramework-infrastructure.yml will deploy all necessary resources to Azure. To enable this functionality, two service principles are required: App Registration service principle (used for az login command) and a Enterprise Application service principle (allows GitHub to authenticate to Azure). +#### Git Hub Environment Secret setup and Azure IAM privileges: +Note: The AZURE_SECRETS method uses: az ad sp create-for-rbac --name "COMPANY-SUB_OR_PRODUCTLINE-github-001" --role contributor --scopes /subscriptions/SUBSCRIPTION_ID --json-auth + +[New-AzureGitHubFederation.ps1](https://github.com/goodtocode/cloud-admin/blob/main/scripts/cybersecurity/Azure-GitHub-Federation/New-AzureGitHubFederation.ps1) +``` +# Install required modules +Install-Module Az.Accounts,Az.Resources -Scope CurrentUser -Force + +# Login to Azure +Connect-AzAccount -SubscriptionId $SubscriptionId -UseDeviceAuthentication + +# Get App Registration object (Application object) +$app = Get-AzADApplication -DisplayName $PrincipalName +if (-not $app) { + $app = New-AzADApplication -DisplayName $PrincipalName +} +Write-Host "App Registration (Client) Id: $($app.AppId)" +$clientId = $app.AppId +$appObjectId = $app.Id + +# Create Service Principal and assign role +$sp = Get-AzADServicePrincipal -DisplayName $PrincipalName +if (-not $sp) { + $sp = New-AzADServicePrincipal -ApplicationId $clientId +} +Write-Host "Service Principal Id: $($sp.Id)" +$spObjectId = $sp.Id +New-AzRoleAssignment -ObjectId $spObjectId -RoleDefinitionName Contributor -Scope "/subscriptions/$SubscriptionId" + +$tenantId = (Get-AzContext).Subscription.TenantId + +# Create new App Registration Federated Credentials for the GitHub operations +$subjectRepo = "repo:" + $Organization + "/" + $Repository + ":environment:" + $Environment +New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-repo" -Subject "$subjectRepo" +$subjectRepoMain = "repo:" + $Organization + "/" + $Repository + ":ref:refs/heads/main" +New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-main" -Subject "$subjectRepoMain" +$subjectRepoPR = "repo:" + $Organization + "/" + $Repository + ":pull_request" +New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-PR" -Subject "$subjectRepoPR" + +Write-Host "AZURE_TENANT_ID: $tenantId" +Write-Host "AZURE_SUBSCRIPTION_ID: $SubscriptionId" +Write-Host "AZURE_CLIENT_ID: $clientId" +``` + +## Azure DevOps Pipelines (.azure-devops folder) +Azure DevOps pipelines require an Azure Service Connection to authenticate and deploy resources to Azure. + +# Entity Framework vs. Semantic Kernel Memory +This example uses Entity Framework (EF) to store messages and responses for Semantic Kernel, and does not rely on SK Memory (SM). EF and SM serve different purposes. If you need natural language querying and efficient indexing, Semantic Kernel Memory is a great fit. If you’re building a standard application with a relational database, Entity Framework is more appropriate. + +The key differences between Entity Framework (EF) and Semantic Kernel memory: + +## Purpose and Functionality +- Entity Framework (EF): EF is an Object-Relational Mapping (ORM) framework for .NET applications. It allows you to map database tables to C# classes and provides an abstraction layer for database operations. EF focuses on CRUD (Create, Read, Update, Delete) operations and data modeling. +- Semantic Kernel Memory: Semantic Kernel Memory (SM) is part of the Semantic Kernel project. It’s a library for C#, Python, and Java that wraps direct calls to databases and supports vector search. SM is designed for long-term memory and efficient indexing of datasets. It’s particularly useful for natural language querying and retrieval augmented generation (RAG). + +## Data Storage and Retrieval +- EF: EF stores data in relational databases (e.g., SQL Server, MySQL, PostgreSQL). It uses SQL queries to retrieve data. +- SM: SM can use various storage mechanisms, including vector databases. It supports vector search, which allows efficient similarity-based retrieval. SM is well-suited for handling large volumes of data and complex queries. + +## Querying +- EF: EF queries are typically written in LINQ (Language Integrated Query) or SQL. You express queries in terms of C# objects and properties. +- SM: SM supports natural language querying. You can search for information using text-based queries, making it more user-friendly for applications like chatbots. + +## Integration with Chat Systems +- EF: EF doesn’t directly integrate with chat systems. It’s primarily used for data persistence. +- SM: SM integrates seamlessly with chat systems like ChatGPT, Copilot, and Semantic Kernel. It enhances data-driven features in AI applications. + +## Scalability and Performance +- EF: EF is suitable for small to medium-sized applications. It may not perform optimally with very large datasets. +- SM: SM is designed for scalability. It can handle large volumes of data efficiently, making it suitable for memory-intensive applications. + +## Use Cases +- EF: Use EF for traditional CRUD operations, business logic, and data modeling. +- SM: Use SM for long-term memory, chatbots, question-answering systems, and information retrieval. + +# Contact +* [GitHub Repo](https://www.github.com/goodtocode/agent-framework-quick-start) +* [@goodtocode](https://www.twitter.com/goodtocode) +* [github.com/goodtocode](https://www.github.com/goodtocode) + +# Technologies +* [ASP.NET Core Fluent UI](https://www.fluentui-blazor.net/) +* [ASP.NET .Net](https://docs.microsoft.com/en-us/aspnet/core/introduction-to-aspnet-core) +* [Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/) + +# Semantic Kernel +* [GitHub](https://github.com/microsoft/semantic-kernel.git) +* [Getting Started Blog](https://devblogs.microsoft.com/semantic-kernel/how-to-get-started-using-semantic-kernel-net/) +* [Understanding the Kernel](https://learn.microsoft.com/en-us/semantic-kernel/agents/kernel/?tabs=Csharp) +* [Creating Plugins](https://devblogs.microsoft.com/semantic-kernel/using-semantic-kernel-to-create-a-time-plugin-with-net/) + +## Additional Technologies References +* AspNetCore.HealthChecks.UI +* Entity Framework Core +* Microsoft.AspNetCore.App +* Microsoft.AspNetCore.Cors +* Microsoft.Aspnetcore.Fluentui +* Swashbuckle.AspNetCore.SwaggerGen +* Swashbuckle.AspNetCore.SwaggerUI + +# Version History + +| Version | Date | Release Notes | +|---------|-------------|------------------------------------------------------------------| +| 1.0.0 | 2024-Aug-05 | Initial WebAPI Release | +| 1.0.1 | 2024-Oct-27 | Updated Azure IaC ESA/CAF Standards | +| 1.0.2 | 2025-Jan-19 | Updated to .NET 9 and SK 1.33 | +| 1.0.3 | 2025-Feb-09 | Remove projects from File-New Project | +| 1.1.0 | 2025-Jun-04 | Blazor copilot-ish UX, AuthorSession | +| 1.1.1 | 2025-Jun-07 | Authors, Sessions & Messages Plugins | +| 1.1.2 | 2025-Aug-16 | Deprecated Specflow, Automapper (removed from solution) | +| 1.1.5 | 2025-Aug-18 | Deprecated FluentValidation/Assertions (removed from solution) | +| 1.1.6 | 2025-Aug-19 | Fixed blazor copilot chat runtime error | +| 1.1.7 | 2025-Aug-22 | Deprecated MediatR (removed from solution) | +| 1.1.8 | 2025-Aug-23 | Updated docs. Fixed runtime message post | +| 1.1.9 | 2025-Oct-31 | Added build/test precursor, plugin compatibility, improved code coverage | +| 2.0.0 | 2026-Feb-02 | Blazor Fluent UI (Microsoft.Aspnetcore.FluentUI) fluentui-blazor.net | + +This project is licensed with the [MIT license](https://mit-license.org/). \ No newline at end of file diff --git a/data/.vscode/tasks.json b/data/.vscode/tasks.json new file mode 100644 index 0000000..295b8a2 --- /dev/null +++ b/data/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Build", + "type": "shell", + "command": "dotnet build", + "problemMatcher": "$sqlproj-problem-matcher", + "isBackground": true, + "group": { + "kind": "build", + "isDefault": "false" + }, + "detail": "Builds the SQL project" + } + ] +} \ No newline at end of file diff --git a/data/Admin/Drop Tables.sql b/data/Admin/Drop Tables.sql new file mode 100644 index 0000000..e2b68a4 --- /dev/null +++ b/data/Admin/Drop Tables.sql @@ -0,0 +1,45 @@ +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[__EFMigrationsHistory]') AND type in (N'U')) +DROP TABLE [dbo].[__EFMigrationsHistory] +GO + +-- +-- Identity +-- +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[Actors]') AND type in (N'U')) +DROP TABLE [dbo].[Actors] +GO + +-- +-- Content +-- +-- Tagging +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[Tags]') AND type in (N'U')) +DROP TABLE [dbo].[Tags] +GO + +-- +-- Chat +-- +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[ChatMessages]') AND type in (N'U')) +DROP TABLE [dbo].[ChatMessages] +GO + +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[ChatSessions]') AND type in (N'U')) +DROP TABLE [dbo].[ChatSessions] +GO + +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[TextAudio]') AND type in (N'U')) +DROP TABLE [dbo].[TextAudio] +GO + +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[TextImages]') AND type in (N'U')) +DROP TABLE [dbo].[TextImages] +GO + +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[TextPrompts]') AND type in (N'U')) +DROP TABLE [dbo].[TextPrompts] +GO + +IF EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[TextResponses]') AND type in (N'U')) +DROP TABLE [dbo].[TextResponses] +GO \ No newline at end of file diff --git a/data/Chat/ChatMessages.sql b/data/Chat/ChatMessages.sql new file mode 100644 index 0000000..e69de29 diff --git a/data/Chat/ChatSessions.sql b/data/Chat/ChatSessions.sql new file mode 100644 index 0000000..e69de29 diff --git a/data/Chat/Multimedia.sql b/data/Chat/Multimedia.sql new file mode 100644 index 0000000..1b1d451 --- /dev/null +++ b/data/Chat/Multimedia.sql @@ -0,0 +1 @@ +-- Write your own SQL object definition here, and it'll be included in your package. diff --git a/data/Data.sqlproj b/data/Data.sqlproj new file mode 100644 index 0000000..b312bcc --- /dev/null +++ b/data/Data.sqlproj @@ -0,0 +1,19 @@ + + + + + Data + {20420E32-B4C5-4E60-8F3F-AEC211310355} + Microsoft.Data.Tools.Schema.Sql.Sql160DatabaseSchemaProvider + 1033, CI + + + + + + + + + + + \ No newline at end of file diff --git a/data/Identity.sql b/data/Identity.sql new file mode 100644 index 0000000..e69de29 diff --git a/docs/AgentFramework-Quick-start-Blazor-Side-by-Side.png b/docs/AgentFramework-Quick-start-Blazor-Side-by-Side.png new file mode 100644 index 0000000000000000000000000000000000000000..c169426cca6644e9064862beabfb137ef13f4d3c GIT binary patch literal 180437 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

Yv1k5j7>2_ z^Ws+?U&sGekhV$dpyo9@o{9*uM{M^q327Jr?;v3glA2{O3v+hk8rJyp-Yfuh0R-JlCR8##bxX zQYL8X4jt<--(j&go1Y1O1Be;zW!{4w>*($sneTb9_C zv$E2kj3>%$hlQfmGY+OY}OD_cq&T}VDRso3?%;l0OliEFMprWUDU|=D%Pti6!6j7Ab zkZOZTX3y*@$IpA7T})c={jZ>nk|KpAT(VCQ;LqN{21saw(;@_X7@S|oJ38>GoukX+ z%o4uEQhLtEf(gD3-o~<+8_z93z&Qdk9SCtqn`tBS{%F`k?eEsqi1v?Apl-ZJE1~KZ0SmHbu8qo_xM_TO z!=t#5X5^OB+cw8w9tP;e_(`<|i%=&kmjq@e^-;nj(P|J`mssR zz?KziH-W$~bCppePlI+)9PqxHKxZV1#2IDwt1l!zhVYj@QgpuMaDK_{>}shJhccqa zuA4VWk4Jk{SEV>N<&zwLf~{(l#vdZs3?0xIWUU4Okst&mn|g?^Bk3E5I=u?k-+r+% zF;W0h2J~@wWzZY~fMw8sTG)D?Tl@#W!ix<7>@gCro(zCK52ad_-C!`{;aq*9R=!sv z5IKKFICDC_QZ*Zh#FRXRb-Bqh=27d=w;W=*zbFa<^M?fhE*OqB{7IxkJk>kfM0+X&cZZK?o8yFBRipF5opLk6g7;X{Y#hNs4Y zI_F$!-2fEe=!MSdt0e1}HY~*F*v!fNqmbJF)%(T7q24CMVpE%b&Q@8!d z&M+`o2hhd`JX!;>zq4Wi<5b*#;brd}{~$10fAr=P9z;9#Ur5?N0KN+UeM~a{2R-}; z^7yZ&@BepaY~t}LBZ|wVT)3dV0wpQTdhN!R*Q#BIqB_AAB7&)3*uoMX9<{Rq1YL6H z0xCUT%7zabsJPf}rSn#2N&DnhF2jIV7U@BIv9jVGFYp8WojoFZ4`8y{Tt5AdKMs|A z|HotIU+5QI-YmTPVbB+#T*`^7#}2D6{>dc1Ms!bAUK$BxQf~SHuzAwm|3(r4 zXB1J;=bAuq#&_j)R+_DkoL1)g_0utLLpU&BVSX|jGNNaiBQc*3)9$Nd$5y@L_(>cs zC#?&BE*~e6tee&!`4>_FAjMwDZCC~sVOuZe#ldv#`Vs44mu3C$=_{~HaD${@*5 zw@(j8h@PQw%JubvONwDIbq551@0dOC~V-?z@C z*lzEfv~!V5xERfX%I)Y?4;|-ur{a|P5qqlRZgHR9yTt8+cUSnW%)}Prn2Bp*`^tLS z1Ck(xW3i)*&M3$LeEr46Cw5t#EIVrE*bGOthumzY$YYeZId!Nq`7XO!gE$9Ny}Lqm zc(rduma&X!Vb;HTPoc{7t21y3BBrMpJ2SInjNH`N*BddHbZn2V+_;i1x)1}LjxeA$ zXIjn$w=7ZzQU6Xr;A`coiHoy9^ThMui*{Ps#iOK?wdD4>$2S2Msq#_*wGhXDnM({g5Imesy2pCtaeb z-aG1}N1zCY4I-rEWFfrHc>Riyd8*Xgt�*pAXy!pnSW;V8s|C+^iT7Q}7c$14{Fp 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≫x;md);5o%m} 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 bGko#+1n2kdd9yu&O&MP>KVNXx`R@M$bgnl_ literal 0 HcmV?d00001 diff --git a/docs/AgentFramework-Quick-start-Blazor-Solution.png b/docs/AgentFramework-Quick-start-Blazor-Solution.png new file mode 100644 index 0000000000000000000000000000000000000000..3c9224a64d2b0a26cb41d64868b8f477e956a63e GIT binary patch literal 15066 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
1u-CY`4n1HA2A`#>6er;Lf~f;B^!H;6P? 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 literal 0 HcmV?d00001 diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..5cedb2b --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,88 @@ +# Setting errors for SDK projects under dotnet folder +[*.cs] +dotnet_separate_import_directive_groups = false +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = block_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_prefer_system_threading_lock = true:suggestion +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_indent_labels = one_less_than_current +#dotnet_diagnostic.CA2007.severity = error # Do not directly await a Task +#dotnet_diagnostic.VSTHRD111.severity = error # Use .ConfigureAwait(bool) +#dotnet_diagnostic.IDE1006.severity = error # Naming rule violations +[*.{cs,vb}] +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +end_of_line = crlf diff --git a/src/App.razor b/src/App.razor new file mode 100644 index 0000000..e69de29 diff --git a/src/Core.Application/Abstractions/IActorResponse.cs b/src/Core.Application/Abstractions/IActorResponse.cs new file mode 100644 index 0000000..d037602 --- /dev/null +++ b/src/Core.Application/Abstractions/IActorResponse.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Abstractions; + +public interface IActorResponse +{ + Guid ActorId { get; } + string? Name { get; } + string Status { get; } + string? Message { get; } +} \ No newline at end of file diff --git a/src/Core.Application/Abstractions/IActorsPlugin.cs b/src/Core.Application/Abstractions/IActorsPlugin.cs new file mode 100644 index 0000000..26eb431 --- /dev/null +++ b/src/Core.Application/Abstractions/IActorsPlugin.cs @@ -0,0 +1,7 @@ +namespace Goodtocode.AgentFramework.Core.Application.Abstractions; + +public interface IActorsPlugin : ISemanticPluginCompatible +{ + Task GetActorByIdAsync(Guid actorId, CancellationToken cancellationToken); + Task> GetActorsByNameAsync(string name, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Core.Application/Abstractions/IChatMessagesPlugin.cs b/src/Core.Application/Abstractions/IChatMessagesPlugin.cs new file mode 100644 index 0000000..cbd022e --- /dev/null +++ b/src/Core.Application/Abstractions/IChatMessagesPlugin.cs @@ -0,0 +1,7 @@ +namespace Goodtocode.AgentFramework.Core.Application.Abstractions; + +public interface IChatMessagesPlugin : ISemanticPluginCompatible +{ + Task> ListRecentMessagesAsync(DateTime? startDate, DateTime? endDate, CancellationToken cancellationToken); + Task> GetChatMessagesAsync(Guid sessionId, CancellationToken cancellationToken); +} diff --git a/src/Core.Application/Abstractions/IChatSessionsPlugin.cs b/src/Core.Application/Abstractions/IChatSessionsPlugin.cs new file mode 100644 index 0000000..ee6d288 --- /dev/null +++ b/src/Core.Application/Abstractions/IChatSessionsPlugin.cs @@ -0,0 +1,7 @@ +namespace Goodtocode.AgentFramework.Core.Application.Abstractions; + +public interface IChatSessionsPlugin : ISemanticPluginCompatible +{ + Task> ListRecentSessionsAsync(DateTime? startDate, DateTime? endDate, CancellationToken cancellationToken); + Task UpdateChatSessionTitleAsync(Guid sessionId, string newTitle, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Core.Application/Abstractions/ISemanticKernelContext.cs b/src/Core.Application/Abstractions/ISemanticKernelContext.cs new file mode 100644 index 0000000..16fa4d9 --- /dev/null +++ b/src/Core.Application/Abstractions/ISemanticKernelContext.cs @@ -0,0 +1,25 @@ +using Goodtocode.AgentFramework.Core.Domain.Actor; +using Goodtocode.AgentFramework.Core.Domain.Audio; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; +using Goodtocode.AgentFramework.Core.Domain.Image; +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace Goodtocode.AgentFramework.Core.Application.Abstractions; + +public interface IAgentFrameworkContext +{ + DbSet ChatMessages { get; } + DbSet ChatSessions {get; } + DbSet TextPrompts { get; } + DbSet TextResponses { get; } + DbSet TextImages { get; } + DbSet TextAudio { get; } + DbSet Actors { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +#pragma warning disable CA1716 // Identifiers should not match keywords + DbSet Set() where TEntity : class; +#pragma warning restore CA1716 + IModel Model { get; } +} \ No newline at end of file diff --git a/src/Core.Application/Abstractions/ISemanticPluginCompatible.cs b/src/Core.Application/Abstractions/ISemanticPluginCompatible.cs new file mode 100644 index 0000000..d10bc56 --- /dev/null +++ b/src/Core.Application/Abstractions/ISemanticPluginCompatible.cs @@ -0,0 +1,8 @@ +namespace Goodtocode.AgentFramework.Core.Application.Abstractions; + +public interface ISemanticPluginCompatible +{ + string PluginName { get; } + string FunctionName { get; } + Dictionary Parameters { get; } +} \ No newline at end of file diff --git a/src/Core.Application/Abstractions/IUserInfoRequest.cs b/src/Core.Application/Abstractions/IUserInfoRequest.cs new file mode 100644 index 0000000..ffb011f --- /dev/null +++ b/src/Core.Application/Abstractions/IUserInfoRequest.cs @@ -0,0 +1,16 @@ +using Goodtocode.AgentFramework.Core.Domain.Auth; + +namespace Goodtocode.AgentFramework.Core.Application.Abstractions; + +/// +/// Represents a request containing user information. +/// +/// This interface is used to encapsulate user information in a request. The property allows getting or setting the associated user details. +public interface IUserInfoRequest +{ + /// + /// Gets or sets the user information associated with the current context. + /// + IUserEntity? UserInfo { get; set; } +} diff --git a/src/Core.Application/Actor/ActorDto.cs b/src/Core.Application/Actor/ActorDto.cs new file mode 100644 index 0000000..133b222 --- /dev/null +++ b/src/Core.Application/Actor/ActorDto.cs @@ -0,0 +1,34 @@ +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class ActorDto +{ + public Guid Id { get; set; } = Guid.Empty; + public string Name { get; set; } = string.Empty; + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public Guid OwnerId { get; set; } + public Guid TenantId { get; set; } + public DateTime CreatedOn { get; private set; } = DateTime.UtcNow; + public DateTime? ModifiedOn { get; private set; } + public DateTime? DeletedOn { get; private set; } + + public static ActorDto CreateFrom(ActorEntity? entity) + { + if (entity is null) return null!; + return new ActorDto + { + Id = entity.Id, + FirstName = entity.FirstName ?? string.Empty, + LastName = entity.LastName ?? string.Empty, + Email = entity.Email ?? string.Empty, + OwnerId = entity.OwnerId, + TenantId = entity.TenantId, + CreatedOn = entity.CreatedOn, + ModifiedOn = entity.ModifiedOn, + DeletedOn = entity.DeletedOn + }; + } +} diff --git a/src/Core.Application/Actor/CreateActorCommand.cs b/src/Core.Application/Actor/CreateActorCommand.cs new file mode 100644 index 0000000..947c171 --- /dev/null +++ b/src/Core.Application/Actor/CreateActorCommand.cs @@ -0,0 +1,57 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class CreateActorCommand : IRequest +{ + public Guid Id { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? Email { get; set; } + public Guid OwnerId { get; set; } + public Guid TenantId { get; set; } +} + +public class CreateAuthorCommandHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(CreateActorCommand request, CancellationToken cancellationToken) + { + GuardAgainstEmptyOwnerId(request?.OwnerId); + GuardAgainstIdExists(_context.Actors, request!.Id); + + var Actor = ActorEntity.Create(request!.Id == Guid.Empty ? Guid.NewGuid() : request!.Id, request.OwnerId, request.TenantId, request.FirstName, request.LastName, request.Email); + _context.Actors.Add(Actor); + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + throw new CustomValidationException( + [ + new("Id", "Id already exists") + ]); + } + + return ActorDto.CreateFrom(Actor); + } + + private static void GuardAgainstEmptyOwnerId(Guid? ownerId) + { + if (ownerId == Guid.Empty) + throw new CustomValidationException( + [ + new("OwnerId", "A OwnerId is required to link an actor with an account") + ]); + } + + private static void GuardAgainstIdExists(DbSet dbSet, Guid id) + { + if (dbSet.Any(x => x.Id == id)) + throw new CustomConflictException("Id already exists"); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/CreateActorCommandValidator.cs b/src/Core.Application/Actor/CreateActorCommandValidator.cs new file mode 100644 index 0000000..45dda71 --- /dev/null +++ b/src/Core.Application/Actor/CreateActorCommandValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class CreateActorCommandValidator : Validator +{ + public CreateActorCommandValidator() + { + RuleFor(x => x.TenantId).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/DeleteActorByExternalIdCommand.cs b/src/Core.Application/Actor/DeleteActorByExternalIdCommand.cs new file mode 100644 index 0000000..c42fe6f --- /dev/null +++ b/src/Core.Application/Actor/DeleteActorByExternalIdCommand.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class DeleteActorByOwnerIdCommand : IRequest +{ + public Guid OwnerId { get; set; } +} + +public class DeleteAuthorByOwnerIdCommandHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(DeleteActorByOwnerIdCommand request, CancellationToken cancellationToken) + { + var actor = await _context.Actors.Where(x => x.OwnerId == request.OwnerId).FirstOrDefaultAsync(cancellationToken); + GuardAgainstNotFound(actor); + + _context.Actors.Remove(actor!); + await _context.SaveChangesAsync(cancellationToken); + } + + private static void GuardAgainstNotFound(ActorEntity? Actor) + { + if (Actor == null) + throw new CustomNotFoundException("Actor Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/DeleteActorByExternalIdCommandValidator.cs b/src/Core.Application/Actor/DeleteActorByExternalIdCommandValidator.cs new file mode 100644 index 0000000..72e2505 --- /dev/null +++ b/src/Core.Application/Actor/DeleteActorByExternalIdCommandValidator.cs @@ -0,0 +1,8 @@ +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class DeleteActorByOwnerIdCommandValidator : Validator +{ + public DeleteActorByOwnerIdCommandValidator() + { + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/DeleteActorCommand.cs b/src/Core.Application/Actor/DeleteActorCommand.cs new file mode 100644 index 0000000..bcfafa8 --- /dev/null +++ b/src/Core.Application/Actor/DeleteActorCommand.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class DeleteActorCommand : IRequest +{ + public Guid Id { get; set; } +} + +public class DeleteAuthorCommandHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(DeleteActorCommand request, CancellationToken cancellationToken) + { + var Actor = _context.Actors.Find(request.Id); + GuardAgainstNotFound(Actor); + + _context.Actors.Remove(Actor!); + await _context.SaveChangesAsync(cancellationToken); + } + + private static void GuardAgainstNotFound(ActorEntity? Actor) + { + if (Actor == null) + throw new CustomNotFoundException("Actor Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/DeleteActorCommandValidator.cs b/src/Core.Application/Actor/DeleteActorCommandValidator.cs new file mode 100644 index 0000000..243d9f1 --- /dev/null +++ b/src/Core.Application/Actor/DeleteActorCommandValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class DeleteActorCommandValidator : Validator +{ + public DeleteActorCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/GetActorChatSessionQuery.cs b/src/Core.Application/Actor/GetActorChatSessionQuery.cs new file mode 100644 index 0000000..ac3ae26 --- /dev/null +++ b/src/Core.Application/Actor/GetActorChatSessionQuery.cs @@ -0,0 +1,32 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class GetActorChatSessionQuery : IRequest +{ + public Guid ActorId { get; set; } + public Guid ChatSessionId { get; set; } +} + +public class GetAuthorChatSessionQueryHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(GetActorChatSessionQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.ChatSessions + .FirstOrDefaultAsync(x => x.Id == request.ChatSessionId && x.ActorId == request.ActorId, cancellationToken: cancellationToken); + GuardAgainstNotFound(returnData); + + return ChatSessionDto.CreateFrom(returnData); + } + + private static void GuardAgainstNotFound(ChatSessionEntity? entity) + { + if (entity is null) + throw new CustomNotFoundException("Chat Session Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/GetActorChatSessionQueryValidator.cs b/src/Core.Application/Actor/GetActorChatSessionQueryValidator.cs new file mode 100644 index 0000000..cb8b0ee --- /dev/null +++ b/src/Core.Application/Actor/GetActorChatSessionQueryValidator.cs @@ -0,0 +1,10 @@ +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class GetActorChatSessionQueryValidator : Validator +{ + public GetActorChatSessionQueryValidator() + { + RuleFor(x => x.ActorId).NotEmpty(); + RuleFor(x => x.ChatSessionId).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/GetActorChatSessionsPaginatedQuery.cs b/src/Core.Application/Actor/GetActorChatSessionsPaginatedQuery.cs new file mode 100644 index 0000000..b39c80d --- /dev/null +++ b/src/Core.Application/Actor/GetActorChatSessionsPaginatedQuery.cs @@ -0,0 +1,34 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Application.Common.Mappings; +using Goodtocode.AgentFramework.Core.Application.Common.Models; + +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class GetActorChatSessionsPaginatedQuery : IRequest> +{ + public Guid ActorId { get; set; } + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 10; +} + +public class GetAuthorChatSessionsPaginatedQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetActorChatSessionsPaginatedQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.ChatSessions + .Include(x => x.Messages) + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Select(x => ChatSessionDto.CreateFrom(x)) + .PaginatedListAsync(request.PageNumber, request.PageSize); + + return returnData; + + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/GetActorChatSessionsPaginatedQueryValidator.cs b/src/Core.Application/Actor/GetActorChatSessionsPaginatedQueryValidator.cs new file mode 100644 index 0000000..209de62 --- /dev/null +++ b/src/Core.Application/Actor/GetActorChatSessionsPaginatedQueryValidator.cs @@ -0,0 +1,22 @@ +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class GetActorChatSessionsPaginatedQueryValidator : Validator +{ + public GetActorChatSessionsPaginatedQueryValidator() + { + RuleFor(x => x.ActorId).NotEmpty(); + + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + + RuleFor(x => x.PageNumber).NotEqual(0); + + RuleFor(x => x.PageSize).NotEqual(0); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/GetActorChatSessionsQuery.cs b/src/Core.Application/Actor/GetActorChatSessionsQuery.cs new file mode 100644 index 0000000..b424343 --- /dev/null +++ b/src/Core.Application/Actor/GetActorChatSessionsQuery.cs @@ -0,0 +1,29 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class GetActorChatSessionsQuery : IRequest> +{ + public Guid ActorId { get; set; } + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } +} + +public class GetAuthorChatSessionsQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetActorChatSessionsQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.ChatSessions + .OrderByDescending(x => x.Timestamp) + .Where(x => x.ActorId == request.ActorId + && (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Select(x => ChatSessionDto.CreateFrom(x)) + .ToListAsync(cancellationToken); + + return returnData; + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/GetActorChatSessionsQueryValidator.cs b/src/Core.Application/Actor/GetActorChatSessionsQueryValidator.cs new file mode 100644 index 0000000..485661f --- /dev/null +++ b/src/Core.Application/Actor/GetActorChatSessionsQueryValidator.cs @@ -0,0 +1,18 @@ +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class GetActorChatSessionsQueryValidator : Validator +{ + public GetActorChatSessionsQueryValidator() + { + RuleFor(x => x.ActorId).NotEmpty(); + + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/GetActorQuery.cs b/src/Core.Application/Actor/GetActorQuery.cs new file mode 100644 index 0000000..0ef2316 --- /dev/null +++ b/src/Core.Application/Actor/GetActorQuery.cs @@ -0,0 +1,29 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class GetActorQuery : IRequest +{ + public Guid ActorId { get; set; } +} + +public class GetAuthorQueryHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(GetActorQuery request, CancellationToken cancellationToken) + { + var actor = await _context.Actors.FindAsync([request.ActorId, cancellationToken], cancellationToken: cancellationToken); + GuardAgainstNotFound(actor); + + return ActorDto.CreateFrom(actor); + } + + private static void GuardAgainstNotFound(ActorEntity? Actor) + { + if (Actor == null) + throw new CustomNotFoundException("Actor Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/GetActorQueryValidator.cs b/src/Core.Application/Actor/GetActorQueryValidator.cs new file mode 100644 index 0000000..9c78093 --- /dev/null +++ b/src/Core.Application/Actor/GetActorQueryValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class GetActorQueryValidator : Validator +{ + public GetActorQueryValidator() + { + RuleFor(x => x.ActorId).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/GetMyActorQuery.cs b/src/Core.Application/Actor/GetMyActorQuery.cs new file mode 100644 index 0000000..d152ea2 --- /dev/null +++ b/src/Core.Application/Actor/GetMyActorQuery.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Actor; +using Goodtocode.AgentFramework.Core.Domain.Auth; + +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class GetMyActorQuery : IRequest, IUserInfoRequest +{ + public IUserEntity? UserInfo { get; set; } +} + +public class GetAuthorByOwnerIdQueryHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(GetMyActorQuery request, CancellationToken cancellationToken) + { + var actor = await _context.Actors.Where(x => x.OwnerId == request!.UserInfo!.OwnerId).FirstOrDefaultAsync(cancellationToken: cancellationToken); + GuardAgainstNotFound(actor); + + return ActorDto.CreateFrom(actor); + } + + private static void GuardAgainstNotFound(ActorEntity? Actor) + { + if (Actor == null) + throw new CustomNotFoundException("Actor Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/GetMyActorQueryValidator.cs b/src/Core.Application/Actor/GetMyActorQueryValidator.cs new file mode 100644 index 0000000..65469c3 --- /dev/null +++ b/src/Core.Application/Actor/GetMyActorQueryValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class GetMyActorQueryValidator : Validator +{ + public GetMyActorQueryValidator() + { + RuleFor(x => x.UserInfo).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/SaveMyActorCommand.cs b/src/Core.Application/Actor/SaveMyActorCommand.cs new file mode 100644 index 0000000..3ad8303 --- /dev/null +++ b/src/Core.Application/Actor/SaveMyActorCommand.cs @@ -0,0 +1,49 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Domain.Actor; +using Goodtocode.AgentFramework.Core.Domain.Auth; + +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class SaveMyActorCommand : IRequest, IUserInfoRequest +{ + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string? Email { get; set; } + public Guid TenantId { get; set; } + public IUserEntity? UserInfo { get; set; } +} + +public class SaveAuthorCommandHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(SaveMyActorCommand request, CancellationToken cancellationToken) + { + GuardAgainstEmptyTenantId(request?.TenantId); + + var actor = await _context.Actors.Where(x => x.OwnerId == request!.UserInfo!.OwnerId && x.TenantId == request.TenantId).FirstOrDefaultAsync(cancellationToken); + if (actor is not null) + { + actor.Update(request?.FirstName, request?.LastName ?? actor.LastName, request?.Email); + _context.Actors.Update(actor!); + } + else + { + actor = ActorEntity.Create(Guid.NewGuid(), request!.UserInfo!.OwnerId, request.TenantId, request.FirstName, request.LastName, request.Email); + _context.Actors.Add(actor); + } + + await _context.SaveChangesAsync(cancellationToken); + + return ActorDto.CreateFrom(actor); + } + + private static void GuardAgainstEmptyTenantId(Guid? tenantId) + { + if (tenantId == Guid.Empty) + throw new CustomValidationException( + [ + new("TenantId", "A TenantId is required to link an actor with an account") + ]); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/SaveMyActorCommandValidator.cs b/src/Core.Application/Actor/SaveMyActorCommandValidator.cs new file mode 100644 index 0000000..955ad78 --- /dev/null +++ b/src/Core.Application/Actor/SaveMyActorCommandValidator.cs @@ -0,0 +1,10 @@ +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class SaveMyActorCommandValidator : Validator +{ + public SaveMyActorCommandValidator() + { + RuleFor(x => x.UserInfo).NotEmpty(); + RuleFor(x => x.TenantId).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/UpdateActorCommand.cs b/src/Core.Application/Actor/UpdateActorCommand.cs new file mode 100644 index 0000000..e3866ee --- /dev/null +++ b/src/Core.Application/Actor/UpdateActorCommand.cs @@ -0,0 +1,41 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class UpdateActorCommand : IRequest +{ + public Guid OwnerId { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class UpdateAuthorCommandHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(UpdateActorCommand request, CancellationToken cancellationToken) + { + GuardAgainstIdEmpty(request.OwnerId); + var actor = await _context.Actors.Where(x => x.OwnerId == request.OwnerId).FirstOrDefaultAsync(cancellationToken); + GuardAgainstNotFound(actor); + + _context.Actors.Update(actor!); + await _context.SaveChangesAsync(cancellationToken); + } + + private static void GuardAgainstIdEmpty(Guid ownerId) + { + if (ownerId == Guid.Empty) + throw new CustomValidationException( + [ + new("OwnerId", "A valid OwnerId is required to update an actor") + ]); + } + + private static void GuardAgainstNotFound(ActorEntity? actor) + { + if (actor == null) + throw new CustomNotFoundException("Actor Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/Actor/UpdateActorCommandValidator.cs b/src/Core.Application/Actor/UpdateActorCommandValidator.cs new file mode 100644 index 0000000..98e8c14 --- /dev/null +++ b/src/Core.Application/Actor/UpdateActorCommandValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Actor; + +public class UpdateActorCommandValidator : Validator +{ + public UpdateActorCommandValidator() + { + RuleFor(x => x.Name).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Audio/CreateTextToAudioCommand.cs b/src/Core.Application/Audio/CreateTextToAudioCommand.cs new file mode 100644 index 0000000..97e3dfd --- /dev/null +++ b/src/Core.Application/Audio/CreateTextToAudioCommand.cs @@ -0,0 +1,66 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Audio; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.TextToAudio; + +namespace Goodtocode.AgentFramework.Core.Application.Audio; + +public class CreateTextToAudioCommand : IRequest +{ + public Guid Id { get; set; } + public Guid ActorId { get; set; } + public string Prompt { get; set; } = string.Empty; +} + +#pragma warning disable SKEXP0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +public class CreateTextToAudioCommandHandler(Kernel kernel, IAgentFrameworkContext context) : IRequestHandler +{ + private readonly Kernel _kernel = kernel; + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(CreateTextToAudioCommand request, CancellationToken cancellationToken) + { + GuardAgainstMissingActor(request.ActorId); + GuardAgainstEmptyPrompt(request?.Prompt); + GuardAgainstIdExists(_context.TextAudio, request!.Id); + + var service = _kernel.GetRequiredService(); + var executionSettings = new PromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + var response = await service.GetAudioContentAsync(request.Prompt, executionSettings, _kernel, cancellationToken); + + var textAudio = TextAudioEntity.Create(request.Id, request.ActorId, request.Prompt, response.Data.GetValueOrDefault().ToArray(), response.Uri); + _context.TextAudio.Add(textAudio); + await _context.SaveChangesAsync(cancellationToken); + + return TextAudioDto.CreateFrom(textAudio); + } + + private static void GuardAgainstMissingActor(Guid actorId) + { + if (actorId == Guid.Empty) + throw new CustomValidationException( + [ + new("ActorId", "ActorId required for sessions") + ]); + } + + private static void GuardAgainstEmptyPrompt(string? prompt) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new CustomValidationException( + [ + new("Prompt", "A prompt is required to get a response") + ]); + } + + private static void GuardAgainstIdExists(DbSet dbSet, Guid id) + { + if (dbSet.Any(x => x.Id == id)) + throw new CustomConflictException("Id already exists"); + } +} +#pragma warning restore SKEXP0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. \ No newline at end of file diff --git a/src/Core.Application/Audio/CreateTextToAudioCommandValidator.cs b/src/Core.Application/Audio/CreateTextToAudioCommandValidator.cs new file mode 100644 index 0000000..39f2226 --- /dev/null +++ b/src/Core.Application/Audio/CreateTextToAudioCommandValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Audio; + +public class CreateTextToAudioCommandValidator : Validator +{ + public CreateTextToAudioCommandValidator() + { + RuleFor(x => x.Prompt).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Audio/DeleteTextAudioCommand.cs b/src/Core.Application/Audio/DeleteTextAudioCommand.cs new file mode 100644 index 0000000..f44f253 --- /dev/null +++ b/src/Core.Application/Audio/DeleteTextAudioCommand.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Audio; + +namespace Goodtocode.AgentFramework.Core.Application.Audio; + +public class DeleteTextAudioCommand : IRequest +{ + public Guid Id { get; set; } +} + +public class DeleteTextAudioCommandHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(DeleteTextAudioCommand request, CancellationToken cancellationToken) + { + var textAudio = _context.TextAudio.Find(request.Id); + GuardAgainstNotFound(textAudio); + + _context.TextAudio.Remove(textAudio!); + await _context.SaveChangesAsync(cancellationToken); + } + + private static void GuardAgainstNotFound(TextAudioEntity? textAudio) + { + if (textAudio == null) + throw new CustomNotFoundException("Text Audio Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/Audio/DeleteTextAudioCommandValidator.cs b/src/Core.Application/Audio/DeleteTextAudioCommandValidator.cs new file mode 100644 index 0000000..9090cce --- /dev/null +++ b/src/Core.Application/Audio/DeleteTextAudioCommandValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Audio; + +public class DeleteTextAudioCommandValidator : Validator +{ + public DeleteTextAudioCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Audio/GetTextAudioQuery.cs b/src/Core.Application/Audio/GetTextAudioQuery.cs new file mode 100644 index 0000000..b1b30b6 --- /dev/null +++ b/src/Core.Application/Audio/GetTextAudioQuery.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Audio; + +namespace Goodtocode.AgentFramework.Core.Application.Audio; + +public class GetTextAudioQuery : IRequest +{ + public Guid Id { get; set; } +} + +public class GetTextAudioQueryHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(GetTextAudioQuery request, + CancellationToken cancellationToken) + { + var textAudio = await _context.TextAudio.FindAsync([request.Id, cancellationToken], cancellationToken: cancellationToken); + GuardAgainstNotFound(textAudio); + + return TextAudioDto.CreateFrom(textAudio); + } + + private static void GuardAgainstNotFound(TextAudioEntity? textAudio) + { + if (textAudio == null) + throw new CustomNotFoundException("Text Audio Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/Audio/GetTextAudioQueryValidator.cs b/src/Core.Application/Audio/GetTextAudioQueryValidator.cs new file mode 100644 index 0000000..c224e12 --- /dev/null +++ b/src/Core.Application/Audio/GetTextAudioQueryValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Audio; + +public class GetTextAudioQueryValidator : Validator +{ + public GetTextAudioQueryValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Audio/GetTextAudiosPaginatedQuery.cs b/src/Core.Application/Audio/GetTextAudiosPaginatedQuery.cs new file mode 100644 index 0000000..e900d78 --- /dev/null +++ b/src/Core.Application/Audio/GetTextAudiosPaginatedQuery.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Mappings; +using Goodtocode.AgentFramework.Core.Application.Common.Models; + +namespace Goodtocode.AgentFramework.Core.Application.Audio; + +public class GetTextAudioPaginatedQuery : IRequest> +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 10; +} + +public class GetTextAudioPaginatedQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetTextAudioPaginatedQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.TextAudio + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Select(x => TextAudioDto.CreateFrom(x)) + .PaginatedListAsync(request.PageNumber, request.PageSize); + + return returnData; + } +} \ No newline at end of file diff --git a/src/Core.Application/Audio/GetTextAudiosPaginatedQueryValidator.cs b/src/Core.Application/Audio/GetTextAudiosPaginatedQueryValidator.cs new file mode 100644 index 0000000..29eb649 --- /dev/null +++ b/src/Core.Application/Audio/GetTextAudiosPaginatedQueryValidator.cs @@ -0,0 +1,20 @@ +namespace Goodtocode.AgentFramework.Core.Application.Audio; + +public class GetTextAudioPaginatedQueryValidator : Validator +{ + public GetTextAudioPaginatedQueryValidator() + { + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + + RuleFor(x => x.PageNumber).NotEqual(0); + + RuleFor(x => x.PageSize).NotEqual(0); + } +} \ No newline at end of file diff --git a/src/Core.Application/Audio/GetTextAudiosQuery.cs b/src/Core.Application/Audio/GetTextAudiosQuery.cs new file mode 100644 index 0000000..6f1af98 --- /dev/null +++ b/src/Core.Application/Audio/GetTextAudiosQuery.cs @@ -0,0 +1,26 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; + +namespace Goodtocode.AgentFramework.Core.Application.Audio; + +public class GetTextAudiosQuery : IRequest> +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } +} + +public class GetTextAudiosQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetTextAudiosQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.TextAudio + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Select(x => TextAudioDto.CreateFrom(x)) + .ToListAsync(cancellationToken); + + return returnData; + } +} \ No newline at end of file diff --git a/src/Core.Application/Audio/GetTextAudiosQueryValidator.cs b/src/Core.Application/Audio/GetTextAudiosQueryValidator.cs new file mode 100644 index 0000000..5e5ecd4 --- /dev/null +++ b/src/Core.Application/Audio/GetTextAudiosQueryValidator.cs @@ -0,0 +1,16 @@ +namespace Goodtocode.AgentFramework.Core.Application.Audio; + +public class GetTextAudiosQueryValidator : Validator +{ + public GetTextAudiosQueryValidator() + { + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + } +} \ No newline at end of file diff --git a/src/Core.Application/Audio/TextAudioDto.cs b/src/Core.Application/Audio/TextAudioDto.cs new file mode 100644 index 0000000..8227aa5 --- /dev/null +++ b/src/Core.Application/Audio/TextAudioDto.cs @@ -0,0 +1,27 @@ +using Goodtocode.AgentFramework.Core.Domain.Audio; + +namespace Goodtocode.AgentFramework.Core.Application.Audio; + +public class TextAudioDto +{ + public Guid Id { get; set; } = Guid.Empty; + public Guid ActorId { get; set; } = Guid.Empty; + public string Description { get; set; } = string.Empty; + public ReadOnlyMemory? AudioBytes { get; set; } + public Uri? AudioUrl { get; set; } + public DateTimeOffset Timestamp { get; set; } + + public static TextAudioDto CreateFrom(TextAudioEntity? entity) + { + if (entity is null) return null!; + return new TextAudioDto + { + Id = entity.Id, + ActorId = entity.ActorId, + Description = entity.Description, + AudioBytes = entity.AudioBytes, + AudioUrl = entity.AudioUrl, + Timestamp = entity.Timestamp + }; + } +} diff --git a/src/Core.Application/ChatCompletion/ChatMessageDto.cs b/src/Core.Application/ChatCompletion/ChatMessageDto.cs new file mode 100644 index 0000000..e08b8bc --- /dev/null +++ b/src/Core.Application/ChatCompletion/ChatMessageDto.cs @@ -0,0 +1,25 @@ +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class ChatMessageDto +{ + public Guid Id { get; set; } = Guid.Empty; + public Guid ChatSessionId { get; set; } = Guid.Empty; + public string Role { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public DateTimeOffset Timestamp { get; set; } + + public static ChatMessageDto CreateFrom(ChatMessageEntity? entity) + { + if (entity is null) return null!; + return new ChatMessageDto + { + Id = entity.Id, + ChatSessionId = entity.ChatSessionId, + Role = entity.Role.ToString(), + Content = entity.Content, + Timestamp = entity.Timestamp + }; + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/ChatSessionDto.cs b/src/Core.Application/ChatCompletion/ChatSessionDto.cs new file mode 100644 index 0000000..f6046cf --- /dev/null +++ b/src/Core.Application/ChatCompletion/ChatSessionDto.cs @@ -0,0 +1,25 @@ +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class ChatSessionDto +{ + public Guid Id { get; set; } = Guid.Empty; + public string Title { get; set; } = string.Empty; + public Guid ActorId { get; set; } = Guid.Empty; + public DateTimeOffset Timestamp { get; set; } + public ICollection? Messages { get; set; } + + public static ChatSessionDto CreateFrom(ChatSessionEntity? entity) + { + if (entity is null) return null!; + return new ChatSessionDto + { + Id = entity.Id, + Title = entity.Title ?? string.Empty, + ActorId = entity.ActorId, + Timestamp = entity.Timestamp, + Messages = entity.Messages?.Select(ChatMessageDto.CreateFrom).ToList() + }; + } +} diff --git a/src/Core.Application/ChatCompletion/CreateChatMessageCommand.cs b/src/Core.Application/ChatCompletion/CreateChatMessageCommand.cs new file mode 100644 index 0000000..2a56b95 --- /dev/null +++ b/src/Core.Application/ChatCompletion/CreateChatMessageCommand.cs @@ -0,0 +1,104 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Auth; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class CreateChatMessageCommand : IRequest, IUserInfoRequest +{ + public Guid Id { get; set; } + public Guid ChatSessionId { get; set; } + public string? Message { get; set; } + public IUserEntity? UserInfo { get; set; } +} + +public class CreateChatMessageCommandHandler(Kernel kernel, IAgentFrameworkContext context) : IRequestHandler +{ + private readonly Kernel _kernel = kernel; + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(CreateChatMessageCommand request, CancellationToken cancellationToken) + { + GuardAgainstSessionNotFound(_context.ChatSessions, request!.ChatSessionId); + GuardAgainstEmptyMessage(request?.Message); + GuardAgainstIdExists(_context.ChatMessages, request!.Id); + GuardAgainstEmptyUser(request?.UserInfo); + GuardAgainstUnauthorizedUser(_context.ChatSessions, request!.UserInfo!); + + var chatSession = _context.ChatSessions.Find(request.ChatSessionId); + + var chatHistory = new ChatHistory(); + foreach (ChatMessageEntity message in chatSession!.Messages) + { + chatHistory.AddUserMessage(message.Content); + } + chatHistory.AddUserMessage(request!.Message!); + var service = _kernel.GetRequiredService(); + var executionSettings = new PromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + var response = await service.GetChatMessageContentAsync(chatHistory, executionSettings, _kernel, cancellationToken); + + var chatMessage = ChatMessageEntity.Create(Guid.NewGuid(), chatSession.Id, ChatMessageRole.user, request.Message!); + chatSession.Messages.Add(chatMessage); + _context.ChatMessages.Add(chatMessage); + + var chatMessageResponse = ChatMessageEntity.Create(Guid.NewGuid(), + chatSession.Id, + Enum.TryParse(response.Role.ToString().ToLowerInvariant(), out var role) ? role : ChatMessageRole.assistant, + response.ToString()); + chatSession.Messages.Add(chatMessageResponse); + _context.ChatMessages.Add(chatMessageResponse); + + await _context.SaveChangesAsync(cancellationToken); + + return ChatMessageDto.CreateFrom(chatMessage); + } + + private static void GuardAgainstSessionNotFound(DbSet dbSet, Guid sessionId) + { + if (sessionId != Guid.Empty && !dbSet.Any(x => x.Id == sessionId)) + throw new CustomValidationException( + [ + new("ChatSessionId", "Chat Session does not exist") + ]); + } + + private static void GuardAgainstEmptyMessage(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + throw new CustomValidationException( + [ + new("Message", "A message is required as a prompt to get an AI response") + ]); + } + + private static void GuardAgainstIdExists(DbSet dbSet, Guid id) + { + if (dbSet.Any(x => x.Id == id)) + throw new CustomConflictException("Id already exists"); + } + + private static void GuardAgainstEmptyUser(IUserEntity? userInfo) + { + if (userInfo == null || userInfo.OwnerId == Guid.Empty || userInfo.TenantId == Guid.Empty) + throw new CustomValidationException( + [ + new("UserInfo", "User information is required to create a chat message") + ]); + } + + private static void GuardAgainstUnauthorizedUser(DbSet dbSet, IUserEntity userInfo) + { + bool isAuthorized = dbSet.Any(x => x.Actor != null && x.Actor.OwnerId == userInfo.OwnerId); + if (!isAuthorized) + throw new CustomValidationException( + [ + new("UserInfo", "User is not authorized to create a chat message in this session") + ]); + } +} diff --git a/src/Core.Application/ChatCompletion/CreateChatMessageCommandValidator.cs b/src/Core.Application/ChatCompletion/CreateChatMessageCommandValidator.cs new file mode 100644 index 0000000..0e4ec20 --- /dev/null +++ b/src/Core.Application/ChatCompletion/CreateChatMessageCommandValidator.cs @@ -0,0 +1,10 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class CreateChatMessageCommandValidator : Validator +{ + public CreateChatMessageCommandValidator() + { + RuleFor(x => x.ChatSessionId).NotEmpty(); + RuleFor(x => x.Message).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/CreateChatSessionCommand.cs b/src/Core.Application/ChatCompletion/CreateChatSessionCommand.cs new file mode 100644 index 0000000..8859257 --- /dev/null +++ b/src/Core.Application/ChatCompletion/CreateChatSessionCommand.cs @@ -0,0 +1,89 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Actor; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class CreateChatSessionCommand : IRequest +{ + public Guid Id { get; set; } + public Guid ActorId { get; set; } + public string? Title { get; set; } + public string? Message { get; set; } +} + +public class CreateChatSessionCommandHandler(Kernel kernel, IAgentFrameworkContext context) : IRequestHandler +{ + private readonly Kernel _kernel = kernel; + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(CreateChatSessionCommand request, CancellationToken cancellationToken) + { + GuardAgainstEmtpyActorId(request.ActorId); + GuardAgainstEmptyMessage(request?.Message); + GuardAgainstIdExists(_context.ChatSessions, request!.Id); + + var service = _kernel.GetRequiredService(); + ChatHistory chatHistory = []; + chatHistory.AddUserMessage(request!.Message!); + var executionSettings = new PromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + var response = await service.GetChatMessageContentAsync(chatHistory, executionSettings, _kernel, cancellationToken); + + var actor = await _context.Actors + .FirstOrDefaultAsync(x => x.OwnerId == request.ActorId, cancellationToken); + GuardAgainstActorNotFound(actor); + + var title = request!.Title ?? $"{request!.Message![..(request.Message!.Length >= 25 ? 25 : request.Message!.Length)]}"; + var chatSession = ChatSessionEntity.Create( + request.Id, + actor!.Id, + title, + Enum.TryParse(response.Role.ToString().ToLowerInvariant(), out var role) ? role : ChatMessageRole.assistant, + request.Message!, + response.ToString() + ); + _context.ChatSessions.Add(chatSession); + await _context.SaveChangesAsync(cancellationToken); + + return ChatSessionDto.CreateFrom(chatSession); + } + + private static void GuardAgainstActorNotFound(ActorEntity? actor) + { + if (actor == null) + throw new CustomValidationException( + [ + new("ActorId", "ActorId required for sessions") + ]); + } + + private static void GuardAgainstEmtpyActorId(Guid actorId) + { + if (actorId == Guid.Empty) + throw new CustomValidationException( + [ + new("ActorId", "ActorId required for sessions") + ]); + } + + private static void GuardAgainstEmptyMessage(string? message) + { + if (string.IsNullOrWhiteSpace(message)) + throw new CustomValidationException( + [ + new("Message", "A message is required to get a response") + ]); + } + + private static void GuardAgainstIdExists(DbSet dbSet, Guid id) + { + if (dbSet.Any(x => x.Id == id)) + throw new CustomConflictException("Id already exists"); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/CreateChatSessionCommandValidator.cs b/src/Core.Application/ChatCompletion/CreateChatSessionCommandValidator.cs new file mode 100644 index 0000000..5763664 --- /dev/null +++ b/src/Core.Application/ChatCompletion/CreateChatSessionCommandValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class CreateChatSessionCommandValidator : Validator +{ + public CreateChatSessionCommandValidator() + { + RuleFor(x => x.Message).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/DeleteChatSessionCommand.cs b/src/Core.Application/ChatCompletion/DeleteChatSessionCommand.cs new file mode 100644 index 0000000..ebdc4fb --- /dev/null +++ b/src/Core.Application/ChatCompletion/DeleteChatSessionCommand.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class DeleteChatSessionCommand : IRequest +{ + public Guid Id { get; set; } +} + +public class DeleteChatSessionCommandHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(DeleteChatSessionCommand request, CancellationToken cancellationToken) + { + var chatSession = _context.ChatSessions.Find(request.Id); + GuardAgainstNotFound(chatSession); + + _context.ChatSessions.Remove(chatSession!); + await _context.SaveChangesAsync(cancellationToken); + } + + private static void GuardAgainstNotFound(ChatSessionEntity? chatSession) + { + if (chatSession == null) + throw new CustomNotFoundException("Chat Session Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/DeleteChatSessionCommandValidator.cs b/src/Core.Application/ChatCompletion/DeleteChatSessionCommandValidator.cs new file mode 100644 index 0000000..844d1c2 --- /dev/null +++ b/src/Core.Application/ChatCompletion/DeleteChatSessionCommandValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class DeleteChatSessionCommandValidator : Validator +{ + public DeleteChatSessionCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/DeleteTextImageCommand.cs b/src/Core.Application/ChatCompletion/DeleteTextImageCommand.cs new file mode 100644 index 0000000..c78d193 --- /dev/null +++ b/src/Core.Application/ChatCompletion/DeleteTextImageCommand.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Image; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class DeleteTextImageCommand : IRequest +{ + public Guid Id { get; set; } +} + +public class DeleteTextImageCommandHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(DeleteTextImageCommand request, CancellationToken cancellationToken) + { + var textImage = await _context.TextImages.FindAsync([request.Id], cancellationToken); + GuardAgainstNotFound(textImage); + + _context.TextImages.Remove(textImage!); + await _context.SaveChangesAsync(cancellationToken); + } + + private static void GuardAgainstNotFound(TextImageEntity? textImage) + { + if (textImage == null) + throw new CustomNotFoundException("Text Image Not Found"); + } +} diff --git a/src/Core.Application/ChatCompletion/DeleteTextImageCommandValidator.cs b/src/Core.Application/ChatCompletion/DeleteTextImageCommandValidator.cs new file mode 100644 index 0000000..5ddfd57 --- /dev/null +++ b/src/Core.Application/ChatCompletion/DeleteTextImageCommandValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class DeleteTextImageCommandValidator : Validator +{ + public DeleteTextImageCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetChatMessageQuery.cs b/src/Core.Application/ChatCompletion/GetChatMessageQuery.cs new file mode 100644 index 0000000..dd659d0 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetChatMessageQuery.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetChatMessageQuery : IRequest +{ + public Guid Id { get; set; } +} + +public class GetChatMessageQueryHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(GetChatMessageQuery request, + CancellationToken cancellationToken) + { + var chatMessage = await _context.ChatMessages.FindAsync([request.Id, cancellationToken], cancellationToken: cancellationToken); + GuardAgainstNotFound(chatMessage); + + return ChatMessageDto.CreateFrom(chatMessage); + } + + private static void GuardAgainstNotFound(ChatMessageEntity? chatMessage) + { + if (chatMessage == null) + throw new CustomNotFoundException("Chat Message Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetChatMessageQueryValidator.cs b/src/Core.Application/ChatCompletion/GetChatMessageQueryValidator.cs new file mode 100644 index 0000000..9129e09 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetChatMessageQueryValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetChatMessageQueryValidator : Validator +{ + public GetChatMessageQueryValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetChatMessagesPaginatedQuery.cs b/src/Core.Application/ChatCompletion/GetChatMessagesPaginatedQuery.cs new file mode 100644 index 0000000..c8ea763 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetChatMessagesPaginatedQuery.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Mappings; +using Goodtocode.AgentFramework.Core.Application.Common.Models; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetChatMessagesPaginatedQuery : IRequest> +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 10; +} + +public class GetChatMessagesPaginatedQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetChatMessagesPaginatedQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.ChatMessages + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Select(x => ChatMessageDto.CreateFrom(x)) + .PaginatedListAsync(request.PageNumber, request.PageSize); + + return returnData; + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetChatMessagesPaginatedQueryValidator.cs b/src/Core.Application/ChatCompletion/GetChatMessagesPaginatedQueryValidator.cs new file mode 100644 index 0000000..3070658 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetChatMessagesPaginatedQueryValidator.cs @@ -0,0 +1,20 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetChatMessagesPaginatedQueryValidator : Validator +{ + public GetChatMessagesPaginatedQueryValidator() + { + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + + RuleFor(x => x.PageNumber).NotEqual(0); + + RuleFor(x => x.PageSize).NotEqual(0); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetChatMessagesQuery.cs b/src/Core.Application/ChatCompletion/GetChatMessagesQuery.cs new file mode 100644 index 0000000..9579430 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetChatMessagesQuery.cs @@ -0,0 +1,26 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetChatMessagesQuery : IRequest> +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } +} + +public class GetChatMessagesQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetChatMessagesQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.ChatMessages + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Select(x => ChatMessageDto.CreateFrom(x)) + .ToListAsync(cancellationToken); + + return returnData; + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetChatMessagesQueryValidator.cs b/src/Core.Application/ChatCompletion/GetChatMessagesQueryValidator.cs new file mode 100644 index 0000000..7b54b41 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetChatMessagesQueryValidator.cs @@ -0,0 +1,16 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetChatMessagesQueryValidator : Validator +{ + public GetChatMessagesQueryValidator() + { + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetChatSessionQuery.cs b/src/Core.Application/ChatCompletion/GetChatSessionQuery.cs new file mode 100644 index 0000000..ed2e271 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetChatSessionQuery.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetChatSessionQuery : IRequest +{ + public Guid Id { get; set; } +} + +public class GetChatSessionQueryHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(GetChatSessionQuery request, + CancellationToken cancellationToken) + { + var chatSession = await _context.ChatSessions.FindAsync([request.Id, cancellationToken], cancellationToken: cancellationToken); + GuardAgainstNotFound(chatSession); + + return ChatSessionDto.CreateFrom(chatSession); + } + + private static void GuardAgainstNotFound(ChatSessionEntity? chatSession) + { + if (chatSession == null) + throw new CustomNotFoundException("Chat Session Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetChatSessionQueryValidator.cs b/src/Core.Application/ChatCompletion/GetChatSessionQueryValidator.cs new file mode 100644 index 0000000..964ae49 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetChatSessionQueryValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetChatSessionQueryValidator : Validator +{ + public GetChatSessionQueryValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetChatSessionsPaginatedQuery.cs b/src/Core.Application/ChatCompletion/GetChatSessionsPaginatedQuery.cs new file mode 100644 index 0000000..a98b117 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetChatSessionsPaginatedQuery.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Mappings; +using Goodtocode.AgentFramework.Core.Application.Common.Models; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetChatSessionsPaginatedQuery : IRequest> +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 10; +} + +public class GetChatSessionsPaginatedQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetChatSessionsPaginatedQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.ChatSessions + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Select(x => ChatSessionDto.CreateFrom(x)) + .PaginatedListAsync(request.PageNumber, request.PageSize); + + return returnData; + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetChatSessionsPaginatedQueryValidator.cs b/src/Core.Application/ChatCompletion/GetChatSessionsPaginatedQueryValidator.cs new file mode 100644 index 0000000..605b382 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetChatSessionsPaginatedQueryValidator.cs @@ -0,0 +1,20 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetChatSessionsPaginatedQueryValidator : Validator +{ + public GetChatSessionsPaginatedQueryValidator() + { + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + + RuleFor(x => x.PageNumber).NotEqual(0); + + RuleFor(x => x.PageSize).NotEqual(0); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetChatSessionsQuery.cs b/src/Core.Application/ChatCompletion/GetChatSessionsQuery.cs new file mode 100644 index 0000000..c8f0636 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetChatSessionsQuery.cs @@ -0,0 +1,26 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetChatSessionsQuery : IRequest> +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } +} + +public class GetChatSessionsQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetChatSessionsQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.ChatSessions + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Select(x => ChatSessionDto.CreateFrom(x)) + .ToListAsync(cancellationToken); + + return returnData; + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetChatSessionsQueryValidator.cs b/src/Core.Application/ChatCompletion/GetChatSessionsQueryValidator.cs new file mode 100644 index 0000000..b0f50ff --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetChatSessionsQueryValidator.cs @@ -0,0 +1,16 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetChatSessionsQueryValidator : Validator +{ + public GetChatSessionsQueryValidator() + { + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetMyChatSessionQuery.cs b/src/Core.Application/ChatCompletion/GetMyChatSessionQuery.cs new file mode 100644 index 0000000..e8470bb --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetMyChatSessionQuery.cs @@ -0,0 +1,40 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Auth; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetMyChatSessionQuery : IRequest, IUserInfoRequest +{ + public Guid ChatSessionId { get; set; } + public IUserEntity? UserInfo { get; set; } +} + +public class GetAuthorChatSessionByOwnerIdQueryHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(GetMyChatSessionQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.ChatSessions + .Where(cs => cs.Id == request.ChatSessionId) + .Join(_context.Actors, + cs => cs.ActorId, + a => a.Id, + (cs, a) => new { ChatSession = cs, Actor = a }) + .Where(joined => joined.Actor.OwnerId == request.UserInfo!.OwnerId) + .Select(joined => joined.ChatSession) + .FirstOrDefaultAsync(cancellationToken); + + GuardAgainstNotFound(returnData); + + return ChatSessionDto.CreateFrom(returnData); + } + + private static void GuardAgainstNotFound(ChatSessionEntity? entity) + { + if (entity is null) + throw new CustomNotFoundException("Chat Session Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetMyChatSessionQueryValidator.cs b/src/Core.Application/ChatCompletion/GetMyChatSessionQueryValidator.cs new file mode 100644 index 0000000..7352d7d --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetMyChatSessionQueryValidator.cs @@ -0,0 +1,10 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetMyChatSessionQueryValidator : Validator +{ + public GetMyChatSessionQueryValidator() + { + RuleFor(x => x.UserInfo).NotEmpty(); + RuleFor(x => x.ChatSessionId).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetMyChatSessionsPaginatedQuery.cs b/src/Core.Application/ChatCompletion/GetMyChatSessionsPaginatedQuery.cs new file mode 100644 index 0000000..10c7754 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetMyChatSessionsPaginatedQuery.cs @@ -0,0 +1,40 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Mappings; +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Core.Domain.Auth; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetMyChatSessionsPaginatedQuery : IRequest>, IUserInfoRequest +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 10; + public IUserEntity? UserInfo { get; set; } +} + +public class GetAuthorChatSessionsByExternalIdPaginatedQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetMyChatSessionsPaginatedQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.ChatSessions + .Include(x => x.Messages) + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Join(_context.Actors, + cs => cs.ActorId, + a => a.Id, + (cs, a) => new { ChatSession = cs, Actor = a }) + .Where(joined => joined.Actor.OwnerId == request.UserInfo!.OwnerId) + .Select(joined => joined.ChatSession) + .Select(x => ChatSessionDto.CreateFrom(x)) + .PaginatedListAsync(request.PageNumber, request.PageSize); + + return returnData; + + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetMyChatSessionsPaginatedQueryValidator.cs b/src/Core.Application/ChatCompletion/GetMyChatSessionsPaginatedQueryValidator.cs new file mode 100644 index 0000000..981ce8a --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetMyChatSessionsPaginatedQueryValidator.cs @@ -0,0 +1,22 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetMyChatSessionsPaginatedQueryValidator : Validator +{ + public GetMyChatSessionsPaginatedQueryValidator() + { + RuleFor(x => x.UserInfo).NotEmpty(); + + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + + RuleFor(x => x.PageNumber).NotEqual(0); + + RuleFor(x => x.PageSize).NotEqual(0); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetMyChatSessionsQuery.cs b/src/Core.Application/ChatCompletion/GetMyChatSessionsQuery.cs new file mode 100644 index 0000000..2f221c1 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetMyChatSessionsQuery.cs @@ -0,0 +1,34 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Domain.Auth; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetMyChatSessionsQuery : IRequest>, IUserInfoRequest +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public IUserEntity? UserInfo { get; set; } +} + +public class GetAuthorChatSessionsByOwnerIdQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetMyChatSessionsQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.ChatSessions + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Join(_context.Actors, + cs => cs.ActorId, + a => a.Id, + (cs, a) => new { ChatSession = cs, Actor = a }) + .Where(joined => joined.Actor.OwnerId == request.UserInfo!.OwnerId) + .Select(joined => joined.ChatSession) + .Select(x => ChatSessionDto.CreateFrom(x)) + .ToListAsync(cancellationToken); + + return returnData; + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/GetMyChatSessionsQueryValidator.cs b/src/Core.Application/ChatCompletion/GetMyChatSessionsQueryValidator.cs new file mode 100644 index 0000000..9c30ea1 --- /dev/null +++ b/src/Core.Application/ChatCompletion/GetMyChatSessionsQueryValidator.cs @@ -0,0 +1,18 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class GetMyChatSessionsQueryValidator : Validator +{ + public GetMyChatSessionsQueryValidator() + { + RuleFor(x => x.UserInfo).NotEmpty(); + + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/PatchChatSessionCommand.cs b/src/Core.Application/ChatCompletion/PatchChatSessionCommand.cs new file mode 100644 index 0000000..ebce817 --- /dev/null +++ b/src/Core.Application/ChatCompletion/PatchChatSessionCommand.cs @@ -0,0 +1,44 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class PatchChatSessionCommand : IRequest +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; +} + +public class PatchChatSessionCommandHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(PatchChatSessionCommand request, CancellationToken cancellationToken) + { + + var chatSession = _context.ChatSessions.Find(request.Id); + GuardAgainstNotFound(chatSession); + GuardAgainstEmptyTitle(request.Title); + + chatSession!.Update(request.Title); + + _context.ChatSessions.Update(chatSession); + await _context.SaveChangesAsync(cancellationToken); + } + + private static void GuardAgainstNotFound(ChatSessionEntity? chatSession) + { + if (chatSession == null) + throw new CustomNotFoundException("Chat Session Not Found"); + } + + private static void GuardAgainstEmptyTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + throw new CustomValidationException( + [ + new("Title", "Title cannot be empty") + ]); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/PatchChatSessionCommandValidator.cs b/src/Core.Application/ChatCompletion/PatchChatSessionCommandValidator.cs new file mode 100644 index 0000000..aedb7bd --- /dev/null +++ b/src/Core.Application/ChatCompletion/PatchChatSessionCommandValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class PatchChatSessionCommandValidator : Validator +{ + public PatchChatSessionCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/UpdateChatSessionCommand.cs b/src/Core.Application/ChatCompletion/UpdateChatSessionCommand.cs new file mode 100644 index 0000000..ee71e69 --- /dev/null +++ b/src/Core.Application/ChatCompletion/UpdateChatSessionCommand.cs @@ -0,0 +1,33 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class UpdateChatSessionCommand : IRequest +{ + public Guid Id { get; set; } + public string Title { get; set; } = string.Empty; + public ICollection Messages { get; set; } = []; +} + +public class UpdateChatSessionCommandHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(UpdateChatSessionCommand request, CancellationToken cancellationToken) + { + var chatSession = _context.ChatSessions.Find(request.Id); + GuardAgainstNotFound(chatSession); + + + _context.ChatSessions.Update(chatSession!); + await _context.SaveChangesAsync(cancellationToken); + } + + private static void GuardAgainstNotFound(ChatSessionEntity? chatSession) + { + if (chatSession == null) + throw new CustomNotFoundException("Chat Session Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/ChatCompletion/UpdateChatSessionCommandValidator.cs b/src/Core.Application/ChatCompletion/UpdateChatSessionCommandValidator.cs new file mode 100644 index 0000000..15ad79b --- /dev/null +++ b/src/Core.Application/ChatCompletion/UpdateChatSessionCommandValidator.cs @@ -0,0 +1,10 @@ +namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +public class UpdateChatSessionCommandValidator : Validator +{ + public UpdateChatSessionCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Title).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Common/Behaviors/CustomLoggingBehavior.cs b/src/Core.Application/Common/Behaviors/CustomLoggingBehavior.cs new file mode 100644 index 0000000..05d8346 --- /dev/null +++ b/src/Core.Application/Common/Behaviors/CustomLoggingBehavior.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.Logging; + +namespace Goodtocode.AgentFramework.Core.Application.Common.Behaviors; + +public class CustomLoggingBehavior(ILogger logger) : IRequestPreProcessor where TRequest : notnull +{ + private readonly ILogger _logger = logger; + + public async Task Process(TRequest request, CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + await Task.Run(() => _logger.LogRequest(requestName), cancellationToken); + } +} \ No newline at end of file diff --git a/src/Core.Application/Common/Behaviors/CustomPerformanceBehavior.cs b/src/Core.Application/Common/Behaviors/CustomPerformanceBehavior.cs new file mode 100644 index 0000000..f912e92 --- /dev/null +++ b/src/Core.Application/Common/Behaviors/CustomPerformanceBehavior.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace Goodtocode.AgentFramework.Core.Application.Common.Behaviors; + +public class CustomPerformanceBehavior( + ILogger logger) : IPipelineBehavior where TRequest : notnull +{ + private readonly Stopwatch _timer = new(); + private readonly ILogger _logger = logger; + + public async Task Handle(TRequest request, RequestDelegateInvoker nextInvoker, CancellationToken cancellationToken) + { + _timer.Start(); + + var response = await nextInvoker(); + + _timer.Stop(); + + var elapsedMilliseconds = _timer.ElapsedMilliseconds; + + if (elapsedMilliseconds > 500) + { + var requestName = typeof(TRequest).Name; + await Task.Run(() => _logger.LogLongRunningRequest(requestName, elapsedMilliseconds), cancellationToken); + } + + return response; + } +} + +public class CustomPerformanceBehavior( + ILogger logger) : IPipelineBehavior where TRequest : notnull +{ + private readonly Stopwatch _timer = new(); + private readonly ILogger _logger = logger; + + public async Task Handle(TRequest request, RequestDelegateInvoker nextInvoker, CancellationToken cancellationToken) + { + _timer.Start(); + + await nextInvoker(); + + _timer.Stop(); + + var elapsedMilliseconds = _timer.ElapsedMilliseconds; + + if (elapsedMilliseconds > 500) + { + var requestName = typeof(TRequest).Name; + await Task.Run(() => _logger.LogLongRunningRequest(requestName, elapsedMilliseconds), cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/Core.Application/Common/Behaviors/CustomUnhandledExceptionBehavior.cs b/src/Core.Application/Common/Behaviors/CustomUnhandledExceptionBehavior.cs new file mode 100644 index 0000000..496a1f3 --- /dev/null +++ b/src/Core.Application/Common/Behaviors/CustomUnhandledExceptionBehavior.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Logging; + +namespace Goodtocode.AgentFramework.Core.Application.Common.Behaviors; + +public class CustomUnhandledExceptionBehavior(ILogger logger) : IPipelineBehavior where TRequest : notnull +{ + private readonly ILogger logger = logger; + + public async Task Handle(TRequest request, RequestDelegateInvoker nextInvoker, CancellationToken cancellationToken) + { + try + { + return await nextInvoker(); + } + catch (Exception ex) + { + var requestName = typeof(TRequest).Name; + await Task.Run(() => logger.LogUnhandledException(ex, requestName), cancellationToken); + throw; + } + } +} + +public class CustomUnhandledExceptionBehavior(ILogger logger) : IPipelineBehavior where TRequest : notnull +{ + private readonly ILogger logger = logger; + + public async Task Handle(TRequest request, RequestDelegateInvoker nextInvoker, CancellationToken cancellationToken) + { + try + { + await nextInvoker(); + } + catch (Exception ex) + { + var requestName = typeof(TRequest).Name; + await Task.Run(() => logger.LogUnhandledException(ex, requestName), cancellationToken); + throw; + } + } +} diff --git a/src/Core.Application/Common/Behaviors/CustomValidationBehavior.cs b/src/Core.Application/Common/Behaviors/CustomValidationBehavior.cs new file mode 100644 index 0000000..500acdd --- /dev/null +++ b/src/Core.Application/Common/Behaviors/CustomValidationBehavior.cs @@ -0,0 +1,44 @@ + +namespace Goodtocode.AgentFramework.Core.Application.Common.Behaviors; + +public class CustomValidationBehavior( + IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull +{ + private readonly IEnumerable> _validators = validators; + + public async Task Handle( + TRequest request, + RequestDelegateInvoker nextInvoker, + CancellationToken cancellationToken) + { + foreach (var validator in _validators) + { + validator.ValidateAndThrow(request); + } + + return await nextInvoker(); + } +} + +public class CustomValidationBehavior( + IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull +{ + private readonly IEnumerable> _validators = validators; + + public Task Handle( + TRequest request, + RequestDelegateInvoker nextInvoker, + CancellationToken cancellationToken) + { + foreach (var validator in _validators) + { + validator.ValidateAndThrow(request); + } + + return nextInvoker(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Common/CustomLoggerExtensions.cs b/src/Core.Application/Common/CustomLoggerExtensions.cs new file mode 100644 index 0000000..90aba23 --- /dev/null +++ b/src/Core.Application/Common/CustomLoggerExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Logging; + +namespace Goodtocode.AgentFramework.Core.Application.Common +{ + public static partial class CustomLoggingBehaviorExtensions + { + [LoggerMessage(EventId = 100, Level = LogLevel.Information, Message = "Request: {Name}")] + public static partial void LogRequest(this ILogger logger, string name); + + [LoggerMessage(EventId = 101, Level = LogLevel.Warning, Message = "Long Running Request: {Name} ({ElapsedMilliseconds} milliseconds)")] + public static partial void LogLongRunningRequest(this ILogger logger, string name, long elapsedMilliseconds); + + [LoggerMessage(EventId = 102, Level = LogLevel.Error, Message = "Request: Unhandled Exception for Request {Name}")] + public static partial void LogUnhandledException(this ILogger logger, Exception exception, string name); + } +} diff --git a/src/Core.Application/Common/Exceptions/CustomConflictException.cs b/src/Core.Application/Common/Exceptions/CustomConflictException.cs new file mode 100644 index 0000000..4e4fb36 --- /dev/null +++ b/src/Core.Application/Common/Exceptions/CustomConflictException.cs @@ -0,0 +1,23 @@ +namespace Goodtocode.AgentFramework.Core.Application.Common.Exceptions; + +public class CustomConflictException : Exception +{ + public CustomConflictException() + { + } + + public CustomConflictException(string message) + : base(message) + { + } + + public CustomConflictException(string message, Exception innerException) + : base(message, innerException) + { + } + + public CustomConflictException(string name, object id) + : base($"Entity \"{name}\" ({id}) conflicts with an existing entity.") + { + } +} \ No newline at end of file diff --git a/src/Core.Application/Common/Exceptions/CustomForbiddenAccessException.cs b/src/Core.Application/Common/Exceptions/CustomForbiddenAccessException.cs new file mode 100644 index 0000000..9994011 --- /dev/null +++ b/src/Core.Application/Common/Exceptions/CustomForbiddenAccessException.cs @@ -0,0 +1,23 @@ +namespace Goodtocode.AgentFramework.Core.Application.Common.Exceptions; + +public class CustomForbiddenAccessException : Exception +{ + public CustomForbiddenAccessException() + { + } + + public CustomForbiddenAccessException(string message) + : base(message) + { + } + + public CustomForbiddenAccessException(string message, Exception innerException) + : base(message, innerException) + { + } + + public CustomForbiddenAccessException(string name, object id) + : base($"Entity \"{name}\" ({id}) was not found.") + { + } +} \ No newline at end of file diff --git a/src/Core.Application/Common/Exceptions/CustomNotFoundException.cs b/src/Core.Application/Common/Exceptions/CustomNotFoundException.cs new file mode 100644 index 0000000..bbb807e --- /dev/null +++ b/src/Core.Application/Common/Exceptions/CustomNotFoundException.cs @@ -0,0 +1,23 @@ +namespace Goodtocode.AgentFramework.Core.Application.Common.Exceptions; + +public class CustomNotFoundException : Exception +{ + public CustomNotFoundException() + { + } + + public CustomNotFoundException(string message) + : base(message) + { + } + + public CustomNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } + + public CustomNotFoundException(string name, object id) + : base($"Entity \"{name}\" ({id}) was not found.") + { + } +} \ No newline at end of file diff --git a/src/Core.Application/Common/Mappings/MappingExtensions.cs b/src/Core.Application/Common/Mappings/MappingExtensions.cs new file mode 100644 index 0000000..ba3f34b --- /dev/null +++ b/src/Core.Application/Common/Mappings/MappingExtensions.cs @@ -0,0 +1,13 @@ +using Goodtocode.AgentFramework.Core.Application.Common.Models; + +namespace Goodtocode.AgentFramework.Core.Application.Common.Mappings; + +public static class MappingExtensions +{ + public static Task> PaginatedListAsync(this IQueryable queryable, int pageNumber, int pageSize) where TDestination : class + { + var paginatedItems = new List(); + var paginatedList = new PaginatedList(paginatedItems, 0, pageNumber, pageSize); + return paginatedList.CreateAsync(queryable.AsNoTracking(), pageNumber, pageSize); + } +} diff --git a/src/Core.Application/Common/Models/PaginatedList.cs b/src/Core.Application/Common/Models/PaginatedList.cs new file mode 100644 index 0000000..ca70b74 --- /dev/null +++ b/src/Core.Application/Common/Models/PaginatedList.cs @@ -0,0 +1,21 @@ +namespace Goodtocode.AgentFramework.Core.Application.Common.Models; + +public class PaginatedList(IReadOnlyCollection items, int count, int pageNumber, int pageSize) +{ + public IReadOnlyCollection Items { get; } = items; + public int PageNumber { get; } = pageNumber; + public int TotalPages { get; } = (int)Math.Ceiling(count / (double)pageSize); + public int TotalCount { get; } = count; + + public bool HasPreviousPage => PageNumber > 1; + + public bool HasNextPage => PageNumber < TotalPages; + + public async Task> CreateAsync(IQueryable source, int pageNumber, int pageSize) + { + var count = await source.CountAsync(); + var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); + + return new PaginatedList(items, count, pageNumber, pageSize); + } +} diff --git a/src/Core.Application/ConfigureServices.cs b/src/Core.Application/ConfigureServices.cs new file mode 100644 index 0000000..e11c20a --- /dev/null +++ b/src/Core.Application/ConfigureServices.cs @@ -0,0 +1,45 @@ +using Goodtocode.AgentFramework.Core.Application.Common.Behaviors; +using Microsoft.Extensions.DependencyInjection; + +namespace Goodtocode.AgentFramework.Core.Application; + +public static class ConfigureServices +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + var handlerTypes = Assembly.GetExecutingAssembly() + .GetTypes() + .Where(t => t.GetInterfaces().Any(i => + i.IsGenericType && + ( + i.GetGenericTypeDefinition() == typeof(IRequestHandler<,>) || + i.GetGenericTypeDefinition() == typeof(IRequestHandler<>) + ) + )); + + foreach (var handlerType in handlerTypes) + { + var interfaceType = handlerType.GetInterfaces().First(i => + i.IsGenericType && + ( + i.GetGenericTypeDefinition() == typeof(IRequestHandler<,>) || + i.GetGenericTypeDefinition() == typeof(IRequestHandler<>) + ) + ); + services.AddTransient(interfaceType, handlerType); + } + + services.AddTransient(typeof(IPipelineBehavior<>), typeof(CustomUnhandledExceptionBehavior<>)); + services.AddTransient(typeof(IPipelineBehavior<>), typeof(CustomValidationBehavior<>)); + services.AddTransient(typeof(IPipelineBehavior<>), typeof(CustomPerformanceBehavior<>)); + + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CustomUnhandledExceptionBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CustomValidationBehavior<,>)); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CustomPerformanceBehavior<,>)); + + services.AddTransient(); + services.AddTransient(); + + return services; + } +} \ No newline at end of file diff --git a/src/Core.Application/Core.Application.csproj b/src/Core.Application/Core.Application.csproj new file mode 100644 index 0000000..29a20f1 --- /dev/null +++ b/src/Core.Application/Core.Application.csproj @@ -0,0 +1,21 @@ + + + + Goodtocode.AgentFramework.Core.Application + Goodtocode.AgentFramework.Core.Application + 1.0.0 + net10.0 + false + enable + enable + + + + + + + + + + + \ No newline at end of file diff --git a/src/Core.Application/GlobalUsings.cs b/src/Core.Application/GlobalUsings.cs new file mode 100644 index 0000000..2d73215 --- /dev/null +++ b/src/Core.Application/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using Goodtocode.Validation; +global using Goodtocode.Mediator; +global using Microsoft.EntityFrameworkCore; +global using System.Reflection; diff --git a/src/Core.Application/Image/CreateTextToImageCommand.cs b/src/Core.Application/Image/CreateTextToImageCommand.cs new file mode 100644 index 0000000..8c840bb --- /dev/null +++ b/src/Core.Application/Image/CreateTextToImageCommand.cs @@ -0,0 +1,57 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Image; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.TextToImage; +using System.Text; + +namespace Goodtocode.AgentFramework.Core.Application.Image; + +public class CreateTextToImageCommand : IRequest +{ + public Guid Id { get; set; } + public string Prompt { get; set; } = string.Empty; + public int Width { get; set; } + public int Height { get; set; } +} + +#pragma warning disable SKEXP0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +public class CreateTextToImageCommandHandler(Kernel kernel, IAgentFrameworkContext context) + : IRequestHandler +{ + private readonly Kernel _kernel = kernel; + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(CreateTextToImageCommand request, CancellationToken cancellationToken) + { + GuardAgainstEmptyPrompt(request?.Prompt); + GuardAgainstIdExists(_context.TextImages, request!.Id); + + var service = _kernel.GetRequiredService(); + var response = await service.GenerateImageAsync(description: request.Prompt, width: request.Width, height: request.Height, cancellationToken: cancellationToken); + // Handle for response containing either a Uri or a Base64 byte array + Uri.TryCreate(response, UriKind.Absolute, out var returnUri); + + var textImage = TextImageEntity.Create(request.Id, request.Prompt, request.Width, request.Height, Encoding.UTF8.GetBytes(response), returnUri); + _context.TextImages.Add(textImage); + await _context.SaveChangesAsync(cancellationToken); + + return TextImageDto.CreateFrom(textImage); + } + + private static void GuardAgainstEmptyPrompt(string? prompt) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new CustomValidationException( + [ + new("Prompt", "A prompt is required to get a response") + ]); + } + + private static void GuardAgainstIdExists(DbSet dbSet, Guid id) + { + if (dbSet.Any(x => x.Id == id)) + throw new CustomConflictException("Id already exists"); + } +} +#pragma warning restore SKEXP0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. \ No newline at end of file diff --git a/src/Core.Application/Image/CreateTextToImageCommandValidator.cs b/src/Core.Application/Image/CreateTextToImageCommandValidator.cs new file mode 100644 index 0000000..4b711f8 --- /dev/null +++ b/src/Core.Application/Image/CreateTextToImageCommandValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Image; + +public class CreateTextToImageCommandValidator : Validator +{ + public CreateTextToImageCommandValidator() + { + RuleFor(x => x.Prompt).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Image/GetTextImageQuery.cs b/src/Core.Application/Image/GetTextImageQuery.cs new file mode 100644 index 0000000..edcce74 --- /dev/null +++ b/src/Core.Application/Image/GetTextImageQuery.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Image; + +namespace Goodtocode.AgentFramework.Core.Application.Image; + +public class GetTextImageQuery : IRequest +{ + public Guid Id { get; set; } +} + +public class GetTextImageQueryHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(GetTextImageQuery request, + CancellationToken cancellationToken) + { + var textImage = await _context.TextImages.FindAsync([request.Id, cancellationToken], cancellationToken: cancellationToken); + GuardAgainstNotFound(textImage); + + return TextImageDto.CreateFrom(textImage); + } + + private static void GuardAgainstNotFound(TextImageEntity? textImage) + { + if (textImage == null) + throw new CustomNotFoundException("Text Image Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/Image/GetTextImageQueryValidator.cs b/src/Core.Application/Image/GetTextImageQueryValidator.cs new file mode 100644 index 0000000..4433098 --- /dev/null +++ b/src/Core.Application/Image/GetTextImageQueryValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.Image; + +public class GetTextImageQueryValidator : Validator +{ + public GetTextImageQueryValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/Image/GetTextImagesPaginatedQuery.cs b/src/Core.Application/Image/GetTextImagesPaginatedQuery.cs new file mode 100644 index 0000000..db1666a --- /dev/null +++ b/src/Core.Application/Image/GetTextImagesPaginatedQuery.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Mappings; +using Goodtocode.AgentFramework.Core.Application.Common.Models; + +namespace Goodtocode.AgentFramework.Core.Application.Image; + +public class GetTextImagesPaginatedQuery : IRequest> +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 10; +} + +public class GetTextImagesPaginatedQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetTextImagesPaginatedQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.TextImages + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Select(x => TextImageDto.CreateFrom(x)) + .PaginatedListAsync(request.PageNumber, request.PageSize); + + return returnData; + } +} \ No newline at end of file diff --git a/src/Core.Application/Image/GetTextImagesPaginatedQueryValidator.cs b/src/Core.Application/Image/GetTextImagesPaginatedQueryValidator.cs new file mode 100644 index 0000000..c750e46 --- /dev/null +++ b/src/Core.Application/Image/GetTextImagesPaginatedQueryValidator.cs @@ -0,0 +1,20 @@ +namespace Goodtocode.AgentFramework.Core.Application.Image; + +public class GetTextImagesPaginatedQueryValidator : Validator +{ + public GetTextImagesPaginatedQueryValidator() + { + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + + RuleFor(x => x.PageNumber).NotEqual(0); + + RuleFor(x => x.PageSize).NotEqual(0); + } +} \ No newline at end of file diff --git a/src/Core.Application/Image/GetTextImagesQuery.cs b/src/Core.Application/Image/GetTextImagesQuery.cs new file mode 100644 index 0000000..ff4a4ab --- /dev/null +++ b/src/Core.Application/Image/GetTextImagesQuery.cs @@ -0,0 +1,26 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; + +namespace Goodtocode.AgentFramework.Core.Application.Image; + +public class GetTextImagesQuery : IRequest> +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } +} + +public class GetTextImagesQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetTextImagesQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.TextImages + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Select(x => TextImageDto.CreateFrom(x)) + .ToListAsync(cancellationToken); + + return returnData; + } +} \ No newline at end of file diff --git a/src/Core.Application/Image/GetTextImagesQueryValidator.cs b/src/Core.Application/Image/GetTextImagesQueryValidator.cs new file mode 100644 index 0000000..192b6ec --- /dev/null +++ b/src/Core.Application/Image/GetTextImagesQueryValidator.cs @@ -0,0 +1,16 @@ +namespace Goodtocode.AgentFramework.Core.Application.Image; + +public class GetTextImagesQueryValidator : Validator +{ + public GetTextImagesQueryValidator() + { + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + } +} \ No newline at end of file diff --git a/src/Core.Application/Image/TextImageDto.cs b/src/Core.Application/Image/TextImageDto.cs new file mode 100644 index 0000000..6b1653c --- /dev/null +++ b/src/Core.Application/Image/TextImageDto.cs @@ -0,0 +1,50 @@ +using Goodtocode.AgentFramework.Core.Domain.Image; + +namespace Goodtocode.AgentFramework.Core.Application.Image; + +public class TextImageDto +{ + private int _width = 1024; + private int _height = 1024; + public Guid Id { get; set; } = Guid.Empty; + public Guid ActorId { get; set; } = Guid.Empty; + public string Description { get; set; } = string.Empty; + public ReadOnlyMemory? ImageBytes { get; set; } + public Uri? ImageUrl { get; set; } + public int Height + { + get => _height; + set => (_height, _width) = value switch + { + 1024 => (1024, 1024), + _ => throw new ArgumentOutOfRangeException("Height", "Must be 1024.") + }; + } + public int Width + { + get => _width; + set => (_height, _width) = value switch + { + 1024 => (1024, 1024), + _ => throw new ArgumentOutOfRangeException("Width", "Must be 1024.") + }; + } + public DateTimeOffset Timestamp { get; set; } + + public static TextImageDto CreateFrom(TextImageEntity? entity) + { + + if (entity is null) return null!; + return new TextImageDto + { + Id = entity.Id, + ActorId = entity.ActorId, + Description = entity.Description, + ImageBytes = entity.ImageBytes, + ImageUrl = entity.ImageUrl, + Width = entity.Width, + Height = entity.Height, + Timestamp = entity.Timestamp + }; + } +} diff --git a/src/Core.Application/TextGeneration/CreateTextPromptCommand.cs b/src/Core.Application/TextGeneration/CreateTextPromptCommand.cs new file mode 100644 index 0000000..e439bfc --- /dev/null +++ b/src/Core.Application/TextGeneration/CreateTextPromptCommand.cs @@ -0,0 +1,57 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.TextGeneration; + +namespace Goodtocode.AgentFramework.Core.Application.TextGeneration; + +public class CreateTextPromptCommand : IRequest +{ + public Guid Id { get; set; } + public string? Prompt { get; set; } +} + +public class CreateTextPromptCommandHandler(Kernel AgentFramework, IAgentFrameworkContext context) : IRequestHandler +{ + private readonly Kernel _kernel = AgentFramework; + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(CreateTextPromptCommand request, CancellationToken cancellationToken) + { + GuardAgainstEmptyPrompt(request?.Prompt); + GuardAgainstIdExists(_context.TextPrompts, request!.Id); + + var service = _kernel.GetRequiredService(); + var executionSettings = new PromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() + }; + var responses = await service.GetTextContentsAsync(request.Prompt!, executionSettings, _kernel, cancellationToken); + + var textPrompt = TextPromptEntity.Create(request.Id, Guid.NewGuid(), request.Prompt!); + foreach (var response in responses) + { + _context.TextResponses.Add(TextResponseEntity.Create(Guid.NewGuid(), textPrompt.Id, response.ToString())); + } + _context.TextPrompts.Add(textPrompt); + await _context.SaveChangesAsync(cancellationToken); + + return TextPromptDto.CreateFrom(textPrompt); + } + + private static void GuardAgainstEmptyPrompt(string? prompt) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new CustomValidationException( + [ + new("Prompt", "A prompt is required to get a response") + ]); + } + + private static void GuardAgainstIdExists(DbSet dbSet, Guid id) + { + if (dbSet.Any(x => x.Id == id)) + throw new CustomConflictException("Id already exists"); + } +} \ No newline at end of file diff --git a/src/Core.Application/TextGeneration/CreateTextPromptCommandValidator.cs b/src/Core.Application/TextGeneration/CreateTextPromptCommandValidator.cs new file mode 100644 index 0000000..b86e8f4 --- /dev/null +++ b/src/Core.Application/TextGeneration/CreateTextPromptCommandValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.TextGeneration; + +public class CreateTextPromptCommandValidator : Validator +{ + public CreateTextPromptCommandValidator() + { + RuleFor(x => x.Prompt).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/TextGeneration/DeleteTextPromptCommand.cs b/src/Core.Application/TextGeneration/DeleteTextPromptCommand.cs new file mode 100644 index 0000000..dc1eb60 --- /dev/null +++ b/src/Core.Application/TextGeneration/DeleteTextPromptCommand.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +namespace Goodtocode.AgentFramework.Core.Application.TextGeneration; + +public class DeleteTextPromptCommand : IRequest +{ + public Guid Id { get; set; } +} + +public class DeleteTextPromptCommandHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(DeleteTextPromptCommand request, CancellationToken cancellationToken) + { + var textPrompt = _context.TextPrompts.Find(request.Id); + GuardAgainstNotFound(textPrompt); + + _context.TextPrompts.Remove(textPrompt!); + await _context.SaveChangesAsync(cancellationToken); + } + + private static void GuardAgainstNotFound(TextPromptEntity? textPrompt) + { + if (textPrompt == null) + throw new CustomNotFoundException("Chat Session Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/TextGeneration/DeleteTextPromptCommandValidator.cs b/src/Core.Application/TextGeneration/DeleteTextPromptCommandValidator.cs new file mode 100644 index 0000000..0c03a41 --- /dev/null +++ b/src/Core.Application/TextGeneration/DeleteTextPromptCommandValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.TextGeneration; + +public class DeleteTextPromptCommandValidator : Validator +{ + public DeleteTextPromptCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/TextGeneration/GetTextPromptQuery.cs b/src/Core.Application/TextGeneration/GetTextPromptQuery.cs new file mode 100644 index 0000000..2f732c8 --- /dev/null +++ b/src/Core.Application/TextGeneration/GetTextPromptQuery.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +namespace Goodtocode.AgentFramework.Core.Application.TextGeneration; + +public class GetTextPromptQuery : IRequest +{ + public Guid Id { get; set; } +} + +public class GetTextPromptQueryHandler(IAgentFrameworkContext context) : IRequestHandler +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task Handle(GetTextPromptQuery request, + CancellationToken cancellationToken) + { + var textPrompt = await _context.TextPrompts.FindAsync([request.Id, cancellationToken], cancellationToken: cancellationToken); + GuardAgainstNotFound(textPrompt); + + return TextPromptDto.CreateFrom(textPrompt); + } + + private static void GuardAgainstNotFound(TextPromptEntity? textPrompt) + { + if (textPrompt == null) + throw new CustomNotFoundException("Chat Session Not Found"); + } +} \ No newline at end of file diff --git a/src/Core.Application/TextGeneration/GetTextPromptQueryValidator.cs b/src/Core.Application/TextGeneration/GetTextPromptQueryValidator.cs new file mode 100644 index 0000000..c57eaa8 --- /dev/null +++ b/src/Core.Application/TextGeneration/GetTextPromptQueryValidator.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Application.TextGeneration; + +public class GetTextPromptQueryValidator : Validator +{ + public GetTextPromptQueryValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Core.Application/TextGeneration/GetTextPromptsPaginatedQuery.cs b/src/Core.Application/TextGeneration/GetTextPromptsPaginatedQuery.cs new file mode 100644 index 0000000..7c47019 --- /dev/null +++ b/src/Core.Application/TextGeneration/GetTextPromptsPaginatedQuery.cs @@ -0,0 +1,30 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Mappings; +using Goodtocode.AgentFramework.Core.Application.Common.Models; + +namespace Goodtocode.AgentFramework.Core.Application.TextGeneration; + +public class GetTextPromptsPaginatedQuery : IRequest> +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 10; +} + +public class GetTextPromptsPaginatedQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetTextPromptsPaginatedQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.TextPrompts + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Select(x => TextPromptDto.CreateFrom(x)) + .PaginatedListAsync(request.PageNumber, request.PageSize); + + return returnData; + } +} \ No newline at end of file diff --git a/src/Core.Application/TextGeneration/GetTextPromptsPaginatedQueryValidator.cs b/src/Core.Application/TextGeneration/GetTextPromptsPaginatedQueryValidator.cs new file mode 100644 index 0000000..2684435 --- /dev/null +++ b/src/Core.Application/TextGeneration/GetTextPromptsPaginatedQueryValidator.cs @@ -0,0 +1,20 @@ +namespace Goodtocode.AgentFramework.Core.Application.TextGeneration; + +public class GetTextPromptsPaginatedQueryValidator : Validator +{ + public GetTextPromptsPaginatedQueryValidator() + { + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + + RuleFor(x => x.PageNumber).NotEqual(0); + + RuleFor(x => x.PageSize).NotEqual(0); + } +} \ No newline at end of file diff --git a/src/Core.Application/TextGeneration/GetTextPromptsQuery.cs b/src/Core.Application/TextGeneration/GetTextPromptsQuery.cs new file mode 100644 index 0000000..f481915 --- /dev/null +++ b/src/Core.Application/TextGeneration/GetTextPromptsQuery.cs @@ -0,0 +1,26 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; + +namespace Goodtocode.AgentFramework.Core.Application.TextGeneration; + +public class GetTextPromptsQuery : IRequest> +{ + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } +} + +public class GetTextPromptsQueryHandler(IAgentFrameworkContext context) : IRequestHandler> +{ + private readonly IAgentFrameworkContext _context = context; + + public async Task> Handle(GetTextPromptsQuery request, CancellationToken cancellationToken) + { + var returnData = await _context.TextPrompts + .OrderByDescending(x => x.Timestamp) + .Where(x => (request.StartDate == null || x.Timestamp > request.StartDate) + && (request.EndDate == null || x.Timestamp < request.EndDate)) + .Select(x => TextPromptDto.CreateFrom(x)) + .ToListAsync(cancellationToken); + + return returnData; + } +} \ No newline at end of file diff --git a/src/Core.Application/TextGeneration/GetTextPromptsQueryValidator.cs b/src/Core.Application/TextGeneration/GetTextPromptsQueryValidator.cs new file mode 100644 index 0000000..187c65a --- /dev/null +++ b/src/Core.Application/TextGeneration/GetTextPromptsQueryValidator.cs @@ -0,0 +1,16 @@ +namespace Goodtocode.AgentFramework.Core.Application.TextGeneration; + +public class GetTextPromptsQueryValidator : Validator +{ + public GetTextPromptsQueryValidator() + { + RuleFor(v => v.StartDate).NotEmpty() + .When(v => v.EndDate != null) + .LessThanOrEqualTo(v => v.EndDate); + + RuleFor(v => v.EndDate) + .NotEmpty() + .When(v => v.StartDate != null) + .GreaterThanOrEqualTo(v => v.StartDate); + } +} \ No newline at end of file diff --git a/src/Core.Application/TextGeneration/TextPromptDto.cs b/src/Core.Application/TextGeneration/TextPromptDto.cs new file mode 100644 index 0000000..892b700 --- /dev/null +++ b/src/Core.Application/TextGeneration/TextPromptDto.cs @@ -0,0 +1,23 @@ +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +namespace Goodtocode.AgentFramework.Core.Application.TextGeneration; + +public class TextPromptDto +{ + public Guid Id { get; set; } = Guid.Empty; + public Guid ActorId { get; set; } = Guid.Empty; + public string Prompt { get; set; } = string.Empty; + public DateTimeOffset Timestamp { get; set; } + + public static TextPromptDto CreateFrom(TextPromptEntity? entity) + { + if (entity is null) return null!; + return new TextPromptDto + { + Id = entity.Id, + ActorId = entity.ActorId, + Prompt = entity.Prompt, + Timestamp = entity.Timestamp, + }; + } +} diff --git a/src/Core.Application/TextGeneration/TextResponseDto.cs b/src/Core.Application/TextGeneration/TextResponseDto.cs new file mode 100644 index 0000000..eedd4fb --- /dev/null +++ b/src/Core.Application/TextGeneration/TextResponseDto.cs @@ -0,0 +1,23 @@ +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +namespace Goodtocode.AgentFramework.Core.Application.TextGeneration; + +public class TextResponseDto +{ + public Guid Id { get; set; } = Guid.Empty; + public Guid TextPromptId { get; set; } = Guid.Empty; + public string Response { get; set; } = string.Empty; + public DateTimeOffset Timestamp { get; set; } + + public static TextResponseDto CreateFrom(TextResponseEntity entity) + { + if (entity is null) return null!; + return new TextResponseDto + { + Id = entity.Id, + TextPromptId = entity.TextPromptId, + Response = entity.Response, + Timestamp = entity.Timestamp + }; + } +} \ No newline at end of file diff --git a/src/Core.Domain/Actor/ActorEntity.cs b/src/Core.Domain/Actor/ActorEntity.cs new file mode 100644 index 0000000..a985aa0 --- /dev/null +++ b/src/Core.Domain/Actor/ActorEntity.cs @@ -0,0 +1,46 @@ +using Goodtocode.AgentFramework.Core.Domain.Auth; +using Goodtocode.Domain.Entities; + +namespace Goodtocode.AgentFramework.Core.Domain.Actor; + +public class ActorEntity : SecuredEntity +{ + protected ActorEntity() { } + + public string? FirstName { get; private set; } = string.Empty; + public string? LastName { get; private set; } = string.Empty; + public string? Email { get; private set; } = string.Empty; + + public static ActorEntity Create(Guid id, Guid ownerId, Guid tenantId, string? firstName, string? lastName, string? email) + { + return new ActorEntity + { + Id = id == Guid.Empty ? Guid.NewGuid() : id, + OwnerId = ownerId, + TenantId = tenantId, + FirstName = firstName, + LastName = lastName, + Email = email + }; + } + + public static ActorEntity Create(IUserEntity userInfo) + { + return new ActorEntity + { + Id = Guid.NewGuid(), + OwnerId = userInfo.OwnerId, + TenantId = userInfo.TenantId, + FirstName = userInfo.FirstName, + LastName = userInfo.LastName, + Email = userInfo.Email + }; + } + + public void Update(string? firstName, string? lastName, string? email) + { + FirstName = firstName ?? FirstName; + LastName = lastName ?? LastName; + Email = email ?? Email; + } +} diff --git a/src/Core.Domain/Audio/TextAudioEntity.cs b/src/Core.Domain/Audio/TextAudioEntity.cs new file mode 100644 index 0000000..67c0ed2 --- /dev/null +++ b/src/Core.Domain/Audio/TextAudioEntity.cs @@ -0,0 +1,47 @@ +using Goodtocode.Domain.Entities; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Core.Domain.Audio; + +public class TextAudioEntity : DomainEntity +{ + private ReadOnlyMemory? _audioBytes; + + protected TextAudioEntity() { } + + public Guid ActorId { get; private set; } = Guid.Empty; + + public string Description { get; private set; } = string.Empty; + + public ReadOnlyMemory? AudioBytes + { + get => _audioBytes; + set => _audioBytes = value.HasValue ? value.Value.ToArray() : null; + } + + public Uri? AudioUrl { get; private set; } + + public virtual ActorEntity Actor { get; private set; } = default!; + public static TextAudioEntity Create(Guid id, Guid authorId, string description, ReadOnlyMemory? audioBytes) + { + return Create(id, authorId, description, audioBytes, null); + } + + public static TextAudioEntity Create(Guid id, Guid authorId, string description, Uri? audioUrl) + { + return Create(id, authorId, description, null, audioUrl); + } + + public static TextAudioEntity Create(Guid id, Guid authorId, string description, ReadOnlyMemory? audioBytes, Uri? audioUrl) + { + return new TextAudioEntity + { + Id = id == Guid.Empty ? Guid.NewGuid() : id, + ActorId = authorId, + Description = description, + AudioBytes = audioBytes, + AudioUrl = audioUrl, + Timestamp = DateTime.UtcNow + }; + } +} diff --git a/src/Core.Domain/Auth/IUserEntity.cs b/src/Core.Domain/Auth/IUserEntity.cs new file mode 100644 index 0000000..55876e0 --- /dev/null +++ b/src/Core.Domain/Auth/IUserEntity.cs @@ -0,0 +1,14 @@ +namespace Goodtocode.AgentFramework.Core.Domain.Auth; + +public interface IUserEntity +{ + Guid OwnerId { get; } + Guid TenantId { get; } + string FirstName { get; } + string LastName { get; } + string Email { get; } + IEnumerable Roles { get; } + bool CanView { get; } + bool CanEdit { get; } + bool CanDelete { get; } +} diff --git a/src/Core.Domain/Auth/UserEntity.cs b/src/Core.Domain/Auth/UserEntity.cs new file mode 100644 index 0000000..3b807b8 --- /dev/null +++ b/src/Core.Domain/Auth/UserEntity.cs @@ -0,0 +1,35 @@ +namespace Goodtocode.AgentFramework.Core.Domain.Auth; + +public struct UserRoles +{ + public const string ChatOwner = "AssetOwner"; + public const string ChatEditor = "AssetEditor"; + public const string ChatViewer = "AssetViewer"; +} + +public class UserEntity() : IUserEntity +{ + public Guid OwnerId { get; private set; } + public Guid TenantId { get; private set; } + public string FirstName { get; private set; } = string.Empty; + public string LastName { get; private set; } = string.Empty; + public string Email { get; private set; } = string.Empty; + public IEnumerable Roles { get; private set; } = []; + public bool CanView => Roles.Contains(UserRoles.ChatOwner) || Roles.Contains(UserRoles.ChatEditor) || Roles.Contains(UserRoles.ChatViewer); + public bool CanEdit => Roles.Contains(UserRoles.ChatOwner) || Roles.Contains(UserRoles.ChatEditor); + public bool CanDelete => Roles.Contains(UserRoles.ChatOwner); + + public static UserEntity Create(Guid ownerId, Guid tenantId, string firstName, string lastName, string email, IEnumerable roles) + { + return new UserEntity + { + OwnerId = ownerId, + TenantId = tenantId, + FirstName = firstName, + LastName = lastName, + Email = email, + Roles = roles + }; + } + +} \ No newline at end of file diff --git a/src/Core.Domain/ChatCompletion/ChatMessageEntity.cs b/src/Core.Domain/ChatCompletion/ChatMessageEntity.cs new file mode 100644 index 0000000..5bb8616 --- /dev/null +++ b/src/Core.Domain/ChatCompletion/ChatMessageEntity.cs @@ -0,0 +1,23 @@ +using Goodtocode.Domain.Entities; + +namespace Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +public class ChatMessageEntity : DomainEntity, IDomainEntity +{ + protected ChatMessageEntity() { } + public Guid ChatSessionId { get; private set; } + public ChatMessageRole Role { get; private set; } + public string Content { get; private set; } = string.Empty; + public virtual ChatSessionEntity? ChatSession { get; private set; } + public static ChatMessageEntity Create(Guid id, Guid chatSessionId, ChatMessageRole role, string content) + { + return new ChatMessageEntity + { + Id = id == Guid.Empty ? Guid.NewGuid() : id, + ChatSessionId = chatSessionId, + Role = role, + Content = content, + Timestamp = DateTime.UtcNow + }; + } +} diff --git a/src/Core.Domain/ChatCompletion/ChatMessageRoles.cs b/src/Core.Domain/ChatCompletion/ChatMessageRoles.cs new file mode 100644 index 0000000..a0ba502 --- /dev/null +++ b/src/Core.Domain/ChatCompletion/ChatMessageRoles.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +public enum ChatMessageRole +{ + system, + user, + assistant, + tool +} diff --git a/src/Core.Domain/ChatCompletion/ChatSessionEntity.cs b/src/Core.Domain/ChatCompletion/ChatSessionEntity.cs new file mode 100644 index 0000000..34044cd --- /dev/null +++ b/src/Core.Domain/ChatCompletion/ChatSessionEntity.cs @@ -0,0 +1,33 @@ +using Goodtocode.Domain.Entities; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +public class ChatSessionEntity : DomainEntity +{ + protected ChatSessionEntity() { } + + public Guid ActorId { get; private set; } + public string? Title { get; private set; } = string.Empty; + public virtual ICollection Messages { get; private set; } = []; + public virtual ActorEntity? Actor { get; private set; } + + public static ChatSessionEntity Create(Guid id, Guid authorId, string? title, ChatMessageRole responseRole, string initialMessage, string responseMessage) + { + var session = new ChatSessionEntity + { + Id = id == Guid.Empty ? Guid.NewGuid() : id, + ActorId = authorId, + Title = title, + Timestamp = DateTime.UtcNow + }; + session.Messages.Add(ChatMessageEntity.Create(Guid.NewGuid(), session.Id, ChatMessageRole.user, initialMessage)); + session.Messages.Add(ChatMessageEntity.Create(Guid.NewGuid(), session.Id, responseRole, responseMessage)); + return session; + } + + public void Update(string? title) + { + Title = title ?? Title; + } +} diff --git a/src/Core.Domain/Core.Domain.csproj b/src/Core.Domain/Core.Domain.csproj new file mode 100644 index 0000000..65c072e --- /dev/null +++ b/src/Core.Domain/Core.Domain.csproj @@ -0,0 +1,17 @@ + + + + Goodtocode.AgentFramework.Core.Domain + Goodtocode.AgentFramework.Core.Domain + 1.0.0 + net10.0 + false + enable + enable + + + + + + + diff --git a/src/Core.Domain/GlobalUsings.cs b/src/Core.Domain/GlobalUsings.cs new file mode 100644 index 0000000..e69de29 diff --git a/src/Core.Domain/Image/TextImageEntity.cs b/src/Core.Domain/Image/TextImageEntity.cs new file mode 100644 index 0000000..be6e7e4 --- /dev/null +++ b/src/Core.Domain/Image/TextImageEntity.cs @@ -0,0 +1,76 @@ +using Goodtocode.Domain.Entities; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Core.Domain.Image; + +public class TextImageEntity : DomainEntity +{ + private int _width = 1024; + private int _height = 1024; + + protected TextImageEntity() { } + + public Guid ActorId { get; private set; } = Guid.Empty; + public string Description { get; private set; } = string.Empty; + public ReadOnlyMemory? ImageBytes { get; private set; } + public Uri? ImageUrl { get; private set; } + public int Height + { + get => _height; + set => (_height, _width) = value switch + { + 1024 => (1024, 1024), + _ => throw new ArgumentOutOfRangeException("Height", "Must be 1024.") + }; + } + public int Width + { + get => _width; + set => (_height, _width) = value switch + { + 1024 => (1024, 1024), + _ => throw new ArgumentOutOfRangeException("Width", "Must be 1024.") + }; + } + public virtual ActorEntity? Actor { get; set; } + + public static TextImageEntity Create( + Guid id, + string description, + int width, + int height, + ReadOnlyMemory? imageBytes) + { + return Create(id, description, width, height, imageBytes, null); + } + + public static TextImageEntity Create( + Guid id, + string description, + int width, + int height, + Uri? imageUrl) + { + return Create(id, description, width, height, null, imageUrl); + } + + public static TextImageEntity Create( + Guid id, + string description, + int width, + int height, + ReadOnlyMemory? imageBytes, + Uri? imageUrl) + { + return new TextImageEntity + { + Id = id == Guid.Empty ? Guid.NewGuid() : id, + Description = description, + Width = width, + Height = height, + ImageBytes = imageBytes, + ImageUrl = imageUrl, + Timestamp = DateTime.UtcNow + }; + } +} diff --git a/src/Core.Domain/TextGeneration/TextPromptEntity.cs b/src/Core.Domain/TextGeneration/TextPromptEntity.cs new file mode 100644 index 0000000..471515f --- /dev/null +++ b/src/Core.Domain/TextGeneration/TextPromptEntity.cs @@ -0,0 +1,24 @@ +using Goodtocode.Domain.Entities; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +public class TextPromptEntity : DomainEntity +{ + protected TextPromptEntity() { } + + public Guid ActorId { get; private set; } = Guid.Empty; + public string Prompt { get; private set; } = string.Empty; + public virtual ActorEntity? Actor { get; private set; } + + public static TextPromptEntity Create(Guid id, Guid authorId, string prompt) + { + return new TextPromptEntity + { + Id = id == Guid.Empty ? Guid.NewGuid() : id, + ActorId = authorId, + Prompt = prompt, + Timestamp = DateTime.UtcNow + }; + } +} diff --git a/src/Core.Domain/TextGeneration/TextResponseEntity.cs b/src/Core.Domain/TextGeneration/TextResponseEntity.cs new file mode 100644 index 0000000..8743aad --- /dev/null +++ b/src/Core.Domain/TextGeneration/TextResponseEntity.cs @@ -0,0 +1,22 @@ +using Goodtocode.Domain.Entities; + +namespace Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +public class TextResponseEntity : DomainEntity +{ + protected TextResponseEntity() { } + + public Guid TextPromptId { get; private set; } = Guid.Empty; + public string Response { get; private set; } = string.Empty; + public virtual TextPromptEntity? TextPrompt { get; private set; } + + public static TextResponseEntity Create(Guid id, Guid textPromptId, string response) + { + return new TextResponseEntity + { + Id = id == Guid.Empty ? Guid.NewGuid() : id, + TextPromptId = textPromptId, + Response = response + }; + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..9be778e --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,38 @@ + + + + true + true + Recommended + latest + false + 12 + enable + enable + + + + + false + + + + + disable + + + + True + + + + $([System.IO.Path]::GetDirectoryName($([MSBuild]::GetPathOfFileAbove('.gitignore', '$(MSBuildThisFileDirectory)')))) + + + + + + <_Parameter1>false + + + \ No newline at end of file diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets new file mode 100644 index 0000000..f4fc12c --- /dev/null +++ b/src/Directory.Build.targets @@ -0,0 +1,13 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Get-CodeCoverage.ps1 b/src/Get-CodeCoverage.ps1 new file mode 100644 index 0000000..5df8c52 --- /dev/null +++ b/src/Get-CodeCoverage.ps1 @@ -0,0 +1,64 @@ +#################################################################################### +# To execute +# 1. In powershell, set security polilcy for this script: +# Set-ExecutionPolicy Unrestricted -Scope Process -Force +# 2. Change directory to the script folder: +# CD src (wherever your script is) +# 3. In powershell, run script: +# .\Get-CodeCoverage.ps1 -TestProjectFilter 'MyTests.*.csproj' -ProdPackagesOnly -ProductionAssemblies 'MyApp.Core','MyApp.Web' +# This script is for local use to analyze code coverage in more detail using HTML report. +#################################################################################### + +Param( + [string]$TestProjectFilter = 'Tests.*.csproj', + [switch]$ProdPackagesOnly = $false, + [string[]]$ProductionAssemblies = @( + "Cannery.Insights.Core.Application", + "Cannery.Insights.Presentation.WebApi", + "Cannery.Insights.Presentation.Blazor" + ) +) +#################################################################################### +if ($IsWindows) {Set-ExecutionPolicy Unrestricted -Scope Process -Force} +$VerbosePreference = 'SilentlyContinue' # 'Continue' +#################################################################################### + +& dotnet tool install -g coverlet.console +& dotnet tool install -g dotnet-reportgenerator-globaltool + +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$scriptPath = Get-Item -Path $PSScriptRoot +$coverageOutputPath = Join-Path $scriptPath "TestResults\Coverage\$timestamp" +$reportOutputPath = Join-Path $scriptPath "TestResults\Reports\$timestamp" + +New-Item -ItemType Directory -Force -Path $coverageOutputPath +New-Item -ItemType Directory -Force -Path $reportOutputPath + +# Find tests for projects with 'Tests.*.csproj' +$testProjects = Get-ChildItem $scriptPath -Filter $TestProjectFilter -Recurse +Write-Host "Found $($testProjects.Count) test projects." +foreach ($project in $testProjects) { + $testProjectPath = $project.FullName + Write-Host "Running tests for project: $($testProjectPath)" + + $buildOutput = Join-Path -Path $project.Directory.FullName -ChildPath "bin\Debug\net9.0\$($project.BaseName).dll" + $coverageFile = Join-Path $coverageOutputPath "coverage.cobertura.xml" + Write-Host "Analyzing code coverage for: $buildOutput" + coverlet $buildOutput --target "dotnet" --targetargs "test $($project.FullName) --no-build" --format cobertura --output $coverageFile + +} + +# Generate HTML report +if ($ProdPackagesOnly) { + $assemblyFilters = ($ProductionAssemblies | ForEach-Object { "+$_" }) -join ";" + $assemblyFilters = ($ProductionAssemblies | ForEach-Object { "+$_" }) -join ";" + & reportgenerator -reports:"$coverageOutputPath/**/coverage.cobertura.xml" -targetdir:$reportOutputPath -reporttypes:Html -assemblyfilters:$assemblyFilters +} +else { + & reportgenerator -reports:"$coverageOutputPath/**/coverage.cobertura.xml" -targetdir:$reportOutputPath -reporttypes:Html +} + +Write-Host "Code coverage report generated at: $reportOutputPath" + +$reportIndexHtml = Join-Path $reportOutputPath "index.html" +Invoke-Item -Path $reportIndexHtml \ No newline at end of file diff --git a/src/Goodtocode.AgentFramework.Blazor.sln b/src/Goodtocode.AgentFramework.Blazor.sln new file mode 100644 index 0000000..879effa --- /dev/null +++ b/src/Goodtocode.AgentFramework.Blazor.sln @@ -0,0 +1,61 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.2.11415.280 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.Domain", "Core.Domain\Core.Domain.csproj", "{008A186A-3688-4860-B52F-BF42F38090FE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.Application", "Core.Application\Core.Application.csproj", "{900FAF69-EDDA-4F1D-BED1-8CD98E453BBE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Presentation.WebApi", "Presentation.WebApi\Presentation.WebApi.csproj", "{9DE6F333-17B7-425D-B05F-E720346B6AF4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Presentation.Blazor", "Presentation.Blazor\Presentation.Blazor.csproj", "{C9E99A27-B6A8-48D3-A9CB-CD8BD45A0D92}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.SqlServer", "Infrastructure.SqlServer\Infrastructure.SqlServer.csproj", "{615694FE-4180-4F1F-9ED7-984BA2DFCEA7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests.Specs.Integration", "Tests.Specs.Integration\Tests.Specs.Integration.csproj", "{5DD2A9DC-4CE9-4E47-BE7D-7EEF208D287F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.AgentFramework", "Infrastructure.AgentFramework\Infrastructure.AgentFramework.csproj", "{21D8E349-812A-A224-C58B-62F682747EFF}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {008A186A-3688-4860-B52F-BF42F38090FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {008A186A-3688-4860-B52F-BF42F38090FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {008A186A-3688-4860-B52F-BF42F38090FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {008A186A-3688-4860-B52F-BF42F38090FE}.Release|Any CPU.Build.0 = Release|Any CPU + {900FAF69-EDDA-4F1D-BED1-8CD98E453BBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {900FAF69-EDDA-4F1D-BED1-8CD98E453BBE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {900FAF69-EDDA-4F1D-BED1-8CD98E453BBE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {900FAF69-EDDA-4F1D-BED1-8CD98E453BBE}.Release|Any CPU.Build.0 = Release|Any CPU + {9DE6F333-17B7-425D-B05F-E720346B6AF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DE6F333-17B7-425D-B05F-E720346B6AF4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DE6F333-17B7-425D-B05F-E720346B6AF4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DE6F333-17B7-425D-B05F-E720346B6AF4}.Release|Any CPU.Build.0 = Release|Any CPU + {C9E99A27-B6A8-48D3-A9CB-CD8BD45A0D92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C9E99A27-B6A8-48D3-A9CB-CD8BD45A0D92}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9E99A27-B6A8-48D3-A9CB-CD8BD45A0D92}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C9E99A27-B6A8-48D3-A9CB-CD8BD45A0D92}.Release|Any CPU.Build.0 = Release|Any CPU + {615694FE-4180-4F1F-9ED7-984BA2DFCEA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {615694FE-4180-4F1F-9ED7-984BA2DFCEA7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {615694FE-4180-4F1F-9ED7-984BA2DFCEA7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {615694FE-4180-4F1F-9ED7-984BA2DFCEA7}.Release|Any CPU.Build.0 = Release|Any CPU + {5DD2A9DC-4CE9-4E47-BE7D-7EEF208D287F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DD2A9DC-4CE9-4E47-BE7D-7EEF208D287F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DD2A9DC-4CE9-4E47-BE7D-7EEF208D287F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DD2A9DC-4CE9-4E47-BE7D-7EEF208D287F}.Release|Any CPU.Build.0 = Release|Any CPU + {21D8E349-812A-A224-C58B-62F682747EFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21D8E349-812A-A224-C58B-62F682747EFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21D8E349-812A-A224-C58B-62F682747EFF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21D8E349-812A-A224-C58B-62F682747EFF}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {394AC88B-6897-416E-B92F-1D370A044F06} + EndGlobalSection +EndGlobal diff --git a/src/Goodtocode.AgentFramework.WebApi.sln b/src/Goodtocode.AgentFramework.WebApi.sln new file mode 100644 index 0000000..d41d256 --- /dev/null +++ b/src/Goodtocode.AgentFramework.WebApi.sln @@ -0,0 +1,55 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.2.11415.280 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.Domain", "Core.Domain\Core.Domain.csproj", "{008A186A-3688-4860-B52F-BF42F38090FE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.Application", "Core.Application\Core.Application.csproj", "{900FAF69-EDDA-4F1D-BED1-8CD98E453BBE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Presentation.WebApi", "Presentation.WebApi\Presentation.WebApi.csproj", "{9DE6F333-17B7-425D-B05F-E720346B6AF4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.SqlServer", "Infrastructure.SqlServer\Infrastructure.SqlServer.csproj", "{615694FE-4180-4F1F-9ED7-984BA2DFCEA7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.SemanticKernel", "Infrastructure.SemanticKernel\Infrastructure.SemanticKernel.csproj", "{2091EF1C-2E75-488C-A822-9628E639FB98}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests.Specs.Integration", "Tests.Specs.Integration\Tests.Specs.Integration.csproj", "{5DD2A9DC-4CE9-4E47-BE7D-7EEF208D287F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {008A186A-3688-4860-B52F-BF42F38090FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {008A186A-3688-4860-B52F-BF42F38090FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {008A186A-3688-4860-B52F-BF42F38090FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {008A186A-3688-4860-B52F-BF42F38090FE}.Release|Any CPU.Build.0 = Release|Any CPU + {900FAF69-EDDA-4F1D-BED1-8CD98E453BBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {900FAF69-EDDA-4F1D-BED1-8CD98E453BBE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {900FAF69-EDDA-4F1D-BED1-8CD98E453BBE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {900FAF69-EDDA-4F1D-BED1-8CD98E453BBE}.Release|Any CPU.Build.0 = Release|Any CPU + {9DE6F333-17B7-425D-B05F-E720346B6AF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DE6F333-17B7-425D-B05F-E720346B6AF4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DE6F333-17B7-425D-B05F-E720346B6AF4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DE6F333-17B7-425D-B05F-E720346B6AF4}.Release|Any CPU.Build.0 = Release|Any CPU + {615694FE-4180-4F1F-9ED7-984BA2DFCEA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {615694FE-4180-4F1F-9ED7-984BA2DFCEA7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {615694FE-4180-4F1F-9ED7-984BA2DFCEA7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {615694FE-4180-4F1F-9ED7-984BA2DFCEA7}.Release|Any CPU.Build.0 = Release|Any CPU + {2091EF1C-2E75-488C-A822-9628E639FB98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2091EF1C-2E75-488C-A822-9628E639FB98}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2091EF1C-2E75-488C-A822-9628E639FB98}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2091EF1C-2E75-488C-A822-9628E639FB98}.Release|Any CPU.Build.0 = Release|Any CPU + {5DD2A9DC-4CE9-4E47-BE7D-7EEF208D287F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DD2A9DC-4CE9-4E47-BE7D-7EEF208D287F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DD2A9DC-4CE9-4E47-BE7D-7EEF208D287F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DD2A9DC-4CE9-4E47-BE7D-7EEF208D287F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {394AC88B-6897-416E-B92F-1D370A044F06} + EndGlobalSection +EndGlobal diff --git a/src/Infrastructure.AgentFramework/AiModels/HuggingfaceModels.cs b/src/Infrastructure.AgentFramework/AiModels/HuggingfaceModels.cs new file mode 100644 index 0000000..eb8d84c --- /dev/null +++ b/src/Infrastructure.AgentFramework/AiModels/HuggingfaceModels.cs @@ -0,0 +1,9 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.AiModels; + +public struct HuggingfaceModels +{ + public const string BertBaseUncased = "bert-base-uncased"; + public const string ChatGpt2 = "gpt2"; + public const string RobertaBase = "roberta-base"; + public const string NokiaNlgpNatural = "nokia/nlgp-natural"; +} diff --git a/src/Infrastructure.AgentFramework/AiModels/OpenAiModels.cs b/src/Infrastructure.AgentFramework/AiModels/OpenAiModels.cs new file mode 100644 index 0000000..fa90573 --- /dev/null +++ b/src/Infrastructure.AgentFramework/AiModels/OpenAiModels.cs @@ -0,0 +1,35 @@ +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.AiModels; + +public struct OpenAiModels +{ + public struct ChatCompletion + { + public const string ChatGpt4 = "gpt-4"; + public const string ChatGpt4Turbo = "gpt-4-turbo"; + public const string ChatGpt35Turbo = "gpt-3.5-turbo"; + } + public struct TextGeneration + { + public const string ChatGpt35TurboInstruct = "gpt-3.5-turbo-instruct"; + } + public struct TextEmbedding + { + public const string TextEmbedding3Large = "text-embedding-3-large"; + public const string TextEmbedding3Small = "text-embedding-3-small"; + } + public struct TextModeration + { + public const string TextModerationLatest = "text-moderation-latest"; + } + public struct Image + { + public const string Dalle3 = "dall-e-3"; + public const string Dalle2 = "dall-e-2"; + } + public struct Audio + { + public const string TextToSpeech1 = "tts-1"; + public const string Whisper1 = "whisper-1"; + public const string Whisper2 = "whisper-2"; + } +} diff --git a/src/Infrastructure.AgentFramework/ConfigureServices.cs b/src/Infrastructure.AgentFramework/ConfigureServices.cs new file mode 100644 index 0000000..320f0b6 --- /dev/null +++ b/src/Infrastructure.AgentFramework/ConfigureServices.cs @@ -0,0 +1,115 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Plugins; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.AudioToText; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.TextGeneration; +using Microsoft.SemanticKernel.TextToAudio; +using Microsoft.SemanticKernel.TextToImage; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework; + +public static class ConfigureServices +{ + public static IServiceCollection AddAgentFrameworkMemoryServices(this IServiceCollection services) + { + //var memory = new KernelMemoryBuilder() + // .WithOpenAIDefaults(Env.Var("OPENAI_API_KEY")) + // .WithSqlServerMemoryDb("YourSqlConnectionString") + // .Build(); + + return services; + } + + public static IServiceCollection AddAgentFrameworkOpenAIServices(this IServiceCollection services, + IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(OpenAIOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + // Plugins + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // TextGenerationService deprecated. Use custom connector service instead. + services.AddSingleton(); + +#pragma warning disable SKEXP0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + // Translate audio to text + services.AddSingleton(sp => + { + var kernel = sp.GetRequiredService(); + return kernel.GetRequiredService(); + }) + // Translate audio to text + .AddSingleton(sp => + { + var kernel = sp.GetRequiredService(); + return kernel.GetRequiredService(); + }) + // Translate text to image + .AddSingleton(sp => + { + var kernel = sp.GetRequiredService(); + return kernel.GetRequiredService(); + }); +#pragma warning restore SKEXP0001 +#pragma warning restore SKEXP0010 + + // Chat Completion + services.AddSingleton(sp => + { + var kernel = sp.GetRequiredService(); + return kernel.GetRequiredService(); + }); + + // To Register the Kernel with no plugins: services.AddKernel(); + // Register the Kernel with plugins imported: + services.AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + var builder = Kernel.CreateBuilder(); + +#pragma warning disable SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + // AI Services + builder.Services + .AddOpenAIChatCompletion(modelId: options.ChatCompletionModelId, apiKey: options.ApiKey) + .AddOpenAIAudioToText(modelId: options.AudioModelId, apiKey: options.ApiKey) + .AddOpenAITextToAudio(modelId: options.AudioModelId, apiKey: options.ApiKey) + .AddOpenAITextToImage(modelId: options.ImageModelId, apiKey: options.ApiKey); +#pragma warning restore SKEXP0010 + + // Logging + builder.Services.AddLogging(logging => + { + logging.SetMinimumLevel(LogLevel.Debug); + }); + + // Memory - ToDo: .WithMemoryStore(new VolatileMemoryStore()); + + var kernel = builder.Build(); + + var authorsPlugin = sp.GetRequiredService(); + var chatSessionsPlugin = sp.GetRequiredService(); + var chatMessagesPlugin = sp.GetRequiredService(); + + kernel.ImportPluginFromObject(authorsPlugin, nameof(ActorsPlugin)); + kernel.ImportPluginFromObject(chatSessionsPlugin, nameof(ChatSessionsPlugin)); + kernel.ImportPluginFromObject(chatMessagesPlugin, nameof(ChatMessagesPlugin)); + + return kernel; + }); + + return services; + } +} \ No newline at end of file diff --git a/src/Infrastructure.AgentFramework/Infrastructure.AgentFramework.csproj b/src/Infrastructure.AgentFramework/Infrastructure.AgentFramework.csproj new file mode 100644 index 0000000..37561e7 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Infrastructure.AgentFramework.csproj @@ -0,0 +1,26 @@ + + + + Goodtocode.AgentFramework.Infrastructure.AgentFramework + Goodtocode.AgentFramework.Infrastructure.AgentFramework + 1.0.0 + net10.0 + false + enable + enable + + + + + + + + + + + + + + + + diff --git a/src/Infrastructure.AgentFramework/Options/AzureOpenAIOptions.cs b/src/Infrastructure.AgentFramework/Options/AzureOpenAIOptions.cs new file mode 100644 index 0000000..f9eb541 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Options/AzureOpenAIOptions.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options; + +/// +/// Azure OpenAI settings. +/// +public sealed class AzureOpenAIOptions +{ + public const string SectionName = "AzureOpenAI"; + + [Required] + public string ChatDeploymentName { get; set; } = string.Empty; + + [Required] + public string Endpoint { get; set; } = string.Empty; + + [Required] + public string ApiKey { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/Infrastructure.AgentFramework/Options/OpenAIOptions.cs b/src/Infrastructure.AgentFramework/Options/OpenAIOptions.cs new file mode 100644 index 0000000..752acd2 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Options/OpenAIOptions.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options; + +/// +/// OpenAI settings. +/// +public sealed class OpenAIOptions +{ + public const string SectionName = "OpenAI"; + + [Required] + public string ChatCompletionModelId { get; set; } = string.Empty; + + [Required] + public string TextGenerationModelId { get; set; } = string.Empty; + + [Required] + public string TextEmbeddingModelId { get; set; } = string.Empty; + + [Required] + public string TextModerationModelId { get; set; } = string.Empty; + + [Required] + public string ImageModelId { get; set; } = string.Empty; + + [Required] + public string AudioModelId { get; set; } = string.Empty; + + [Required] + public string ApiKey { get; set; } = string.Empty; +} diff --git a/src/Infrastructure.AgentFramework/Plugins/ActorsPlugin.cs b/src/Infrastructure.AgentFramework/Plugins/ActorsPlugin.cs new file mode 100644 index 0000000..ad8fb0b --- /dev/null +++ b/src/Infrastructure.AgentFramework/Plugins/ActorsPlugin.cs @@ -0,0 +1,124 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using System.ComponentModel; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Plugins; + +public class ActorResponse : IActorResponse +{ + public Guid ActorId { get; set; } + public string? Name { get; set; } + public string Status { get; set; } = string.Empty; + public string? Message { get; set; } +} + + +public sealed class ActorsPlugin(IServiceProvider serviceProvider) : IActorsPlugin +{ + private readonly IServiceProvider _serviceProvider = serviceProvider; + + public string PluginName => "AuthorsPlugin"; + public string FunctionName => _currentFunctionName; + public Dictionary Parameters => _currentParameters; + + private string _currentFunctionName = string.Empty; + private Dictionary _currentParameters = []; + + [KernelFunction("get_actor_by_id")] + [Description("Returns structured actor info by ID including name, status, and explanation.")] + public async Task GetActorByIdAsync(Guid actorId, CancellationToken cancellationToken) + { + _currentFunctionName = "get_actor_by_id"; + _currentParameters = new() + { + { "actorId", actorId } + }; + + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var actor = await context.Actors.FindAsync([actorId, cancellationToken], cancellationToken: cancellationToken); + + if (actor == null) + { + return new ActorResponse + { + ActorId = actorId, + Name = null, + Status = "NotFound", + Message = "No actor found with the specified ID." + }; + } + + return new ActorResponse + { + ActorId = actorId, + Name = $"{actor.FirstName} {actor.LastName}", + Status = string.IsNullOrWhiteSpace($"{actor.FirstName} {actor.LastName}") ? "Partial" : "Found", + Message = string.IsNullOrWhiteSpace($"{actor.FirstName} {actor.LastName}") + ? "Actor exists but name is not yet linked to Entra External ID." + : "Actor found." + }; + } + + [KernelFunction("get_actors_by_name")] + [Description("Returns structured actor info by name including ID, status, and explanation.")] + public async Task> GetActorsByNameAsync(string name, CancellationToken cancellationToken) + { + _currentFunctionName = "get_actors_by_name"; + _currentParameters = new() + { + { "name", name } + }; + + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var nameTokens = name?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; + var normalizedInput = name?.Trim() ?? string.Empty; + + var authors = await context.Actors + .Where(a => + nameTokens.Any(token => + EF.Functions.Like(a.FirstName, $"%{token}%") || + EF.Functions.Like(a.LastName, $"%{token}%") + ) + || EF.Functions.Like( + (a.FirstName + " " + a.LastName).Trim(), $"%{normalizedInput}%" + ) + || EF.Functions.Like( + (a.LastName + " " + a.FirstName).Trim(), $"%{normalizedInput}%" + ) + || nameTokens.Any(token => + EF.Functions.Like(a.FirstName, $"{token}%") || + EF.Functions.Like(a.FirstName, $"%{token}") || + EF.Functions.Like(a.LastName, $"{token}%") || + EF.Functions.Like(a.LastName, $"%{token}") + ) + ) + .ToListAsync(cancellationToken); + + if (authors.Count == 0) + { + return [ new ActorResponse + { + ActorId = Guid.Empty, + Name = name, + Status = "NotFound", + Message = "No actor found with the specified name." + } ]; + } + else + { + return [.. authors.Select(a => new ActorResponse + { + ActorId = a.Id, + Name = $"{a.FirstName} {a.LastName}", + Status = string.IsNullOrWhiteSpace($"{a.FirstName} {a.LastName}") ? "Partial" : "Found", + Message = string.IsNullOrWhiteSpace($"{a.FirstName} {a.LastName}") + ? "Actor exists but name is not yet linked to Entra External ID." + : "Actor found." + })]; + } + } +} diff --git a/src/Infrastructure.AgentFramework/Plugins/ChatMessagesPlugin.cs b/src/Infrastructure.AgentFramework/Plugins/ChatMessagesPlugin.cs new file mode 100644 index 0000000..8125a41 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Plugins/ChatMessagesPlugin.cs @@ -0,0 +1,63 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using System.ComponentModel; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Plugins; + +public sealed class ChatMessagesPlugin(IServiceProvider serviceProvider) : IChatMessagesPlugin +{ + private readonly IServiceProvider _serviceProvider = serviceProvider; + + public string PluginName => "ChatMessagesPlugin"; + public string FunctionName => _currentFunctionName; + public Dictionary Parameters => _currentParameters; + + private string _currentFunctionName = string.Empty; + private Dictionary _currentParameters = []; + + [KernelFunction("list_messages")] + [Description("Retrieves the most recent messages from all chat sessions.")] + public async Task> ListRecentMessagesAsync(DateTime? startDate = null, DateTime? endDate = null, + CancellationToken cancellationToken = default) + { + _currentFunctionName = "list_messages"; + _currentParameters = new() + { + { "startDate", startDate ?? DateTime.UtcNow.AddDays(-7) }, + { "endDate", endDate ?? DateTime.UtcNow.AddSeconds(1)} + }; + + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var query = context.ChatMessages.AsQueryable(); + if (startDate.HasValue) query = query.Where(x => x.Timestamp >= startDate.Value); + if (endDate.HasValue) query = query.Where(x => x.Timestamp <= endDate.Value); + + var messages = await query.OrderByDescending(x => x.Timestamp).ToListAsync(cancellationToken); + return messages.Select(m => $"{m.ChatSessionId}: {m.Timestamp:u} - {m.Role}: {m.Content}"); + } + + [KernelFunction("get_messages")] + [Description("Retrieves all messages from a specific chat session.")] + public async Task> GetChatMessagesAsync(Guid sessionId, + CancellationToken cancellationToken = default) + { + _currentFunctionName = "get_messages"; + _currentParameters = new() + { + { "sessionId", sessionId } + }; + + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var messages = await context.ChatMessages + .Where(x => x.ChatSessionId == sessionId) + .ToListAsync(cancellationToken); + + return messages.Select(m => $"{m.ChatSessionId}: {m.Timestamp:u} - {m.Role}: {m.Content}"); + } +} \ No newline at end of file diff --git a/src/Infrastructure.AgentFramework/Plugins/ChatSessionsPlugin.cs b/src/Infrastructure.AgentFramework/Plugins/ChatSessionsPlugin.cs new file mode 100644 index 0000000..b68059e --- /dev/null +++ b/src/Infrastructure.AgentFramework/Plugins/ChatSessionsPlugin.cs @@ -0,0 +1,76 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.SemanticKernel; +using System.ComponentModel; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Plugins; + +public sealed class ChatSessionsPlugin(IServiceProvider serviceProvider) : IChatSessionsPlugin +{ + private readonly IServiceProvider _serviceProvider = serviceProvider; + + public string PluginName => "ChatSessionsPlugin"; + public string FunctionName => _currentFunctionName; + public Dictionary Parameters => _currentParameters; + + private string _currentFunctionName = string.Empty; + private Dictionary _currentParameters = []; + + [KernelFunction("list_sessions")] + [Description("Retrieves a list of recent chat sessions. Optionally, filter results by start and/or end date to narrow the search.")] + public async Task> ListRecentSessionsAsync(DateTime? startDate = null, DateTime? endDate = null, CancellationToken cancellationToken = default) + { + _currentFunctionName = "list_sessions"; + _currentParameters = new() + { + { "startDate", startDate ?? DateTime.UtcNow.AddDays(-7) }, + { "endDate", endDate ?? DateTime.UtcNow.AddSeconds(1)} + }; + + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var query = context.ChatSessions.AsQueryable(); + + if (startDate.HasValue) + query = query.Where(x => x.Timestamp > startDate.Value); + if (endDate.HasValue) + query = query.Where(x => x.Timestamp < endDate.Value); + + var messages = await query + .OrderByDescending(x => x.Timestamp) + .ToListAsync(cancellationToken); + + return messages.Select(m => $"{m.Id}: {m.Timestamp} - {m.Title}"); + } + + [KernelFunction("change_title")] + [Description("Changes the title on this chat session.")] + public async Task UpdateChatSessionTitleAsync(Guid sessionId, string newTitle, CancellationToken cancellationToken = default) + { + _currentFunctionName = "change_title"; + _currentParameters = new() + { + { "sessionId", sessionId }, + { "newTitle", newTitle } + }; + + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var chatSession = await context.ChatSessions + .FirstOrDefaultAsync(x => x.Id == sessionId, cancellationToken: cancellationToken); + + if (chatSession == null) + { + return $"Session {sessionId} not found."; + } + + chatSession.Update(newTitle); + context.ChatSessions.Update(chatSession); + await context.SaveChangesAsync(cancellationToken); + + return $"{chatSession.Id}: {chatSession.Timestamp} - {chatSession.Title}: {chatSession.Actor?.FirstName} {chatSession.Actor?.LastName}"; + } +} \ No newline at end of file diff --git a/src/Infrastructure.AgentFramework/Services/TextGenerationService.cs b/src/Infrastructure.AgentFramework/Services/TextGenerationService.cs new file mode 100644 index 0000000..70af986 --- /dev/null +++ b/src/Infrastructure.AgentFramework/Services/TextGenerationService.cs @@ -0,0 +1,39 @@ +using System.Runtime.CompilerServices; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.TextGeneration; + +namespace Goodtocode.AgentFramework.Infrastructure.AgentFramework.Services; + +public class TextGenerationService(IChatCompletionService chatCompletionService) : ITextGenerationService +{ + private readonly IChatCompletionService _chatCompletionService = chatCompletionService; + + public IReadOnlyDictionary Attributes => throw new NotImplementedException(); + + public async IAsyncEnumerable GetStreamingTextContentsAsync( + string prompt, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var chatMessage in _chatCompletionService.GetStreamingChatMessageContentsAsync(prompt, executionSettings, kernel, cancellationToken)) + { + yield return new StreamingTextContent(chatMessage.Content); + } + } + public async Task> GetTextContentsAsync( + string prompt, + PromptExecutionSettings? executionSettings = null, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + var chatMessageContents = await _chatCompletionService.GetChatMessageContentsAsync(prompt, executionSettings, kernel, cancellationToken); + var textContents = chatMessageContents + .Select(chatMessage => new TextContent(chatMessage.Content)) + .ToList(); + + return textContents; + } + +} \ No newline at end of file diff --git a/src/Infrastructure.SqlServer/ColumnTypes.cs b/src/Infrastructure.SqlServer/ColumnTypes.cs new file mode 100644 index 0000000..854cf4f --- /dev/null +++ b/src/Infrastructure.SqlServer/ColumnTypes.cs @@ -0,0 +1,13 @@ +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer; + +public static class ColumnTypes +{ + public const string DateTime2 = "DATETIME2"; + public const string Nvarchar100 = "NVARCHAR(100)"; + public const string Nvarchar200 = "NVARCHAR(200)"; + public const string Nvarchar1000 = "NVARCHAR(1000)"; + public const string NvarcharMax = "NVARCHAR(MAX)"; + public const string VarbinaryMax = "VARBINARY(MAX)"; + public const string VarcharMax = "VARCHAR(MAX)"; + public const string Uniqueidentifier = "UNIQUEIDENTIFIER"; +} diff --git a/src/Infrastructure.SqlServer/ConfigureServices.cs b/src/Infrastructure.SqlServer/ConfigureServices.cs new file mode 100644 index 0000000..6ad9e0b --- /dev/null +++ b/src/Infrastructure.SqlServer/ConfigureServices.cs @@ -0,0 +1,22 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer; + +public static class ConfigureServices +{ + public static IServiceCollection AddDbContextServices(this IServiceCollection services, + IConfiguration configuration) + { + services.AddDbContext(options => + options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"), + builder => builder.MigrationsAssembly(typeof(AgentFrameworkContext).Assembly.FullName)) + .UseLazyLoadingProxies()); + + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/src/Infrastructure.SqlServer/GlobalUsings.cs b/src/Infrastructure.SqlServer/GlobalUsings.cs new file mode 100644 index 0000000..e61446b --- /dev/null +++ b/src/Infrastructure.SqlServer/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Microsoft.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore.Metadata.Builders; \ No newline at end of file diff --git a/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj b/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj new file mode 100644 index 0000000..9f7ef2e --- /dev/null +++ b/src/Infrastructure.SqlServer/Infrastructure.SqlServer.csproj @@ -0,0 +1,30 @@ + + + Goodtocode.AgentFramework.Infrastructure.SqlServer + Goodtocode.AgentFramework.Infrastructure.SqlServer + 1.0.0 + net10.0 + false + enable + enable + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Infrastructure.SqlServer/JsonSerializerOptionsProvider.cs b/src/Infrastructure.SqlServer/JsonSerializerOptionsProvider.cs new file mode 100644 index 0000000..a65599b --- /dev/null +++ b/src/Infrastructure.SqlServer/JsonSerializerOptionsProvider.cs @@ -0,0 +1,12 @@ +using System.Text.Json; + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer; + +public static class JsonSerializerOptionsProvider +{ + public static readonly JsonSerializerOptions Default = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; +} \ No newline at end of file diff --git a/src/Infrastructure.SqlServer/Migrations/20260130074609_InitialCreate.Designer.cs b/src/Infrastructure.SqlServer/Migrations/20260130074609_InitialCreate.Designer.cs new file mode 100644 index 0000000..7c0e711 --- /dev/null +++ b/src/Infrastructure.SqlServer/Migrations/20260130074609_InitialCreate.Designer.cs @@ -0,0 +1,441 @@ +// +using System; +using Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Migrations +{ + [DbContext(typeof(AgentFrameworkContext))] + [Migration("20260130074609_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.Actor.ActorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("NVARCHAR(200)"); + + b.Property("FirstName") + .HasColumnType("NVARCHAR(200)"); + + b.Property("LastName") + .HasColumnType("NVARCHAR(200)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("OwnerId") + .HasColumnType("UNIQUEIDENTIFIER"); + + b.Property("TenantId") + .HasColumnType("UNIQUEIDENTIFIER"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.HasIndex("TenantId", "OwnerId") + .IsUnique(); + + b.ToTable("Actors", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.Audio.TextAudioEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("AudioBytes") + .HasColumnType("VARBINARY(MAX)"); + + b.Property("AudioUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("ActorId"); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.ToTable("TextAudio", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.ChatCompletion.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ChatSessionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("ChatSessionId"); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.ToTable("ChatMessages", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.ChatCompletion.ChatSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("ActorId"); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.ToTable("ChatSessions", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.Image.TextImageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Height") + .HasColumnType("int"); + + b.Property("ImageBytes") + .HasColumnType("VARBINARY(MAX)"); + + b.Property("ImageUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("Width") + .HasColumnType("int"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("ActorId"); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.ToTable("TextImages", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.TextGeneration.TextPromptEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("ActorId"); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.ToTable("TextPrompts", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.TextGeneration.TextResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Response") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TextPromptId") + .HasColumnType("uniqueidentifier"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("TextPromptId"); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.ToTable("TextResponses", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.Audio.TextAudioEntity", b => + { + b.HasOne("Goodtocode.AgentFramework.Core.Domain.Actor.ActorEntity", "Actor") + .WithMany() + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Actor"); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.ChatCompletion.ChatMessageEntity", b => + { + b.HasOne("Goodtocode.AgentFramework.Core.Domain.ChatCompletion.ChatSessionEntity", "ChatSession") + .WithMany("Messages") + .HasForeignKey("ChatSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatSession"); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.ChatCompletion.ChatSessionEntity", b => + { + b.HasOne("Goodtocode.AgentFramework.Core.Domain.Actor.ActorEntity", "Actor") + .WithMany() + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Actor"); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.Image.TextImageEntity", b => + { + b.HasOne("Goodtocode.AgentFramework.Core.Domain.Actor.ActorEntity", "Actor") + .WithMany() + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Actor"); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.TextGeneration.TextPromptEntity", b => + { + b.HasOne("Goodtocode.AgentFramework.Core.Domain.Actor.ActorEntity", "Actor") + .WithMany() + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Actor"); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.TextGeneration.TextResponseEntity", b => + { + b.HasOne("Goodtocode.AgentFramework.Core.Domain.TextGeneration.TextPromptEntity", "TextPrompt") + .WithMany() + .HasForeignKey("TextPromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TextPrompt"); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.ChatCompletion.ChatSessionEntity", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure.SqlServer/Migrations/20260130074609_InitialCreate.cs b/src/Infrastructure.SqlServer/Migrations/20260130074609_InitialCreate.cs new file mode 100644 index 0000000..8ca5169 --- /dev/null +++ b/src/Infrastructure.SqlServer/Migrations/20260130074609_InitialCreate.cs @@ -0,0 +1,346 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Actors", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + FirstName = table.Column(type: "NVARCHAR(200)", nullable: true), + LastName = table.Column(type: "NVARCHAR(200)", nullable: true), + Email = table.Column(type: "NVARCHAR(200)", nullable: true), + CreatedOn = table.Column(type: "datetime2", nullable: false), + ModifiedOn = table.Column(type: "datetime2", nullable: true), + DeletedOn = table.Column(type: "datetime2", nullable: true), + Timestamp = table.Column(type: "datetimeoffset", nullable: false), + OwnerId = table.Column(type: "UNIQUEIDENTIFIER", nullable: false), + TenantId = table.Column(type: "UNIQUEIDENTIFIER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Actors", x => x.Id) + .Annotation("SqlServer:Clustered", false); + }); + + migrationBuilder.CreateTable( + name: "ChatSessions", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ActorId = table.Column(type: "uniqueidentifier", nullable: false), + Title = table.Column(type: "nvarchar(max)", nullable: true), + CreatedOn = table.Column(type: "datetime2", nullable: false), + ModifiedOn = table.Column(type: "datetime2", nullable: true), + DeletedOn = table.Column(type: "datetime2", nullable: true), + Timestamp = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatSessions", x => x.Id) + .Annotation("SqlServer:Clustered", false); + table.ForeignKey( + name: "FK_ChatSessions_Actors_ActorId", + column: x => x.ActorId, + principalTable: "Actors", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TextAudio", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ActorId = table.Column(type: "uniqueidentifier", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: false), + AudioBytes = table.Column(type: "VARBINARY(MAX)", nullable: true), + AudioUrl = table.Column(type: "nvarchar(max)", nullable: true), + CreatedOn = table.Column(type: "datetime2", nullable: false), + ModifiedOn = table.Column(type: "datetime2", nullable: true), + DeletedOn = table.Column(type: "datetime2", nullable: true), + Timestamp = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TextAudio", x => x.Id) + .Annotation("SqlServer:Clustered", false); + table.ForeignKey( + name: "FK_TextAudio_Actors_ActorId", + column: x => x.ActorId, + principalTable: "Actors", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TextImages", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ActorId = table.Column(type: "uniqueidentifier", nullable: false), + Description = table.Column(type: "nvarchar(max)", nullable: false), + ImageBytes = table.Column(type: "VARBINARY(MAX)", nullable: true), + ImageUrl = table.Column(type: "nvarchar(max)", nullable: true), + Height = table.Column(type: "int", nullable: false), + Width = table.Column(type: "int", nullable: false), + CreatedOn = table.Column(type: "datetime2", nullable: false), + ModifiedOn = table.Column(type: "datetime2", nullable: true), + DeletedOn = table.Column(type: "datetime2", nullable: true), + Timestamp = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TextImages", x => x.Id) + .Annotation("SqlServer:Clustered", false); + table.ForeignKey( + name: "FK_TextImages_Actors_ActorId", + column: x => x.ActorId, + principalTable: "Actors", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TextPrompts", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ActorId = table.Column(type: "uniqueidentifier", nullable: false), + Prompt = table.Column(type: "nvarchar(max)", nullable: false), + CreatedOn = table.Column(type: "datetime2", nullable: false), + ModifiedOn = table.Column(type: "datetime2", nullable: true), + DeletedOn = table.Column(type: "datetime2", nullable: true), + Timestamp = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TextPrompts", x => x.Id) + .Annotation("SqlServer:Clustered", false); + table.ForeignKey( + name: "FK_TextPrompts_Actors_ActorId", + column: x => x.ActorId, + principalTable: "Actors", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ChatMessages", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ChatSessionId = table.Column(type: "uniqueidentifier", nullable: false), + Role = table.Column(type: "int", nullable: false), + Content = table.Column(type: "nvarchar(max)", nullable: false), + CreatedOn = table.Column(type: "datetime2", nullable: false), + ModifiedOn = table.Column(type: "datetime2", nullable: true), + DeletedOn = table.Column(type: "datetime2", nullable: true), + Timestamp = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChatMessages", x => x.Id) + .Annotation("SqlServer:Clustered", false); + table.ForeignKey( + name: "FK_ChatMessages_ChatSessions_ChatSessionId", + column: x => x.ChatSessionId, + principalTable: "ChatSessions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TextResponses", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TextPromptId = table.Column(type: "uniqueidentifier", nullable: false), + Response = table.Column(type: "nvarchar(max)", nullable: false), + CreatedOn = table.Column(type: "datetime2", nullable: false), + ModifiedOn = table.Column(type: "datetime2", nullable: true), + DeletedOn = table.Column(type: "datetime2", nullable: true), + Timestamp = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TextResponses", x => x.Id) + .Annotation("SqlServer:Clustered", false); + table.ForeignKey( + name: "FK_TextResponses_TextPrompts_TextPromptId", + column: x => x.TextPromptId, + principalTable: "TextPrompts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Actors_Id", + table: "Actors", + column: "Id", + unique: true) + .Annotation("SqlServer:Clustered", false); + + migrationBuilder.CreateIndex( + name: "IX_Actors_TenantId_OwnerId", + table: "Actors", + columns: new[] { "TenantId", "OwnerId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Actors_Timestamp", + table: "Actors", + column: "Timestamp", + unique: true) + .Annotation("SqlServer:Clustered", true); + + migrationBuilder.CreateIndex( + name: "IX_ChatMessages_ChatSessionId", + table: "ChatMessages", + column: "ChatSessionId"); + + migrationBuilder.CreateIndex( + name: "IX_ChatMessages_Id", + table: "ChatMessages", + column: "Id", + unique: true) + .Annotation("SqlServer:Clustered", false); + + migrationBuilder.CreateIndex( + name: "IX_ChatMessages_Timestamp", + table: "ChatMessages", + column: "Timestamp", + unique: true) + .Annotation("SqlServer:Clustered", true); + + migrationBuilder.CreateIndex( + name: "IX_ChatSessions_ActorId", + table: "ChatSessions", + column: "ActorId"); + + migrationBuilder.CreateIndex( + name: "IX_ChatSessions_Id", + table: "ChatSessions", + column: "Id", + unique: true) + .Annotation("SqlServer:Clustered", false); + + migrationBuilder.CreateIndex( + name: "IX_ChatSessions_Timestamp", + table: "ChatSessions", + column: "Timestamp", + unique: true) + .Annotation("SqlServer:Clustered", true); + + migrationBuilder.CreateIndex( + name: "IX_TextAudio_ActorId", + table: "TextAudio", + column: "ActorId"); + + migrationBuilder.CreateIndex( + name: "IX_TextAudio_Id", + table: "TextAudio", + column: "Id", + unique: true) + .Annotation("SqlServer:Clustered", false); + + migrationBuilder.CreateIndex( + name: "IX_TextAudio_Timestamp", + table: "TextAudio", + column: "Timestamp", + unique: true) + .Annotation("SqlServer:Clustered", true); + + migrationBuilder.CreateIndex( + name: "IX_TextImages_ActorId", + table: "TextImages", + column: "ActorId"); + + migrationBuilder.CreateIndex( + name: "IX_TextImages_Id", + table: "TextImages", + column: "Id", + unique: true) + .Annotation("SqlServer:Clustered", false); + + migrationBuilder.CreateIndex( + name: "IX_TextImages_Timestamp", + table: "TextImages", + column: "Timestamp", + unique: true) + .Annotation("SqlServer:Clustered", true); + + migrationBuilder.CreateIndex( + name: "IX_TextPrompts_ActorId", + table: "TextPrompts", + column: "ActorId"); + + migrationBuilder.CreateIndex( + name: "IX_TextPrompts_Id", + table: "TextPrompts", + column: "Id", + unique: true) + .Annotation("SqlServer:Clustered", false); + + migrationBuilder.CreateIndex( + name: "IX_TextPrompts_Timestamp", + table: "TextPrompts", + column: "Timestamp", + unique: true) + .Annotation("SqlServer:Clustered", true); + + migrationBuilder.CreateIndex( + name: "IX_TextResponses_Id", + table: "TextResponses", + column: "Id", + unique: true) + .Annotation("SqlServer:Clustered", false); + + migrationBuilder.CreateIndex( + name: "IX_TextResponses_TextPromptId", + table: "TextResponses", + column: "TextPromptId"); + + migrationBuilder.CreateIndex( + name: "IX_TextResponses_Timestamp", + table: "TextResponses", + column: "Timestamp", + unique: true) + .Annotation("SqlServer:Clustered", true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChatMessages"); + + migrationBuilder.DropTable( + name: "TextAudio"); + + migrationBuilder.DropTable( + name: "TextImages"); + + migrationBuilder.DropTable( + name: "TextResponses"); + + migrationBuilder.DropTable( + name: "ChatSessions"); + + migrationBuilder.DropTable( + name: "TextPrompts"); + + migrationBuilder.DropTable( + name: "Actors"); + } + } +} diff --git a/src/Infrastructure.SqlServer/Migrations/SemanticKernelContextModelSnapshot.cs b/src/Infrastructure.SqlServer/Migrations/SemanticKernelContextModelSnapshot.cs new file mode 100644 index 0000000..19c8e5e --- /dev/null +++ b/src/Infrastructure.SqlServer/Migrations/SemanticKernelContextModelSnapshot.cs @@ -0,0 +1,438 @@ +// +using System; +using Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Migrations +{ + [DbContext(typeof(AgentFrameworkContext))] + partial class AgentFrameworkContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.Actor.ActorEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("NVARCHAR(200)"); + + b.Property("FirstName") + .HasColumnType("NVARCHAR(200)"); + + b.Property("LastName") + .HasColumnType("NVARCHAR(200)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("OwnerId") + .HasColumnType("UNIQUEIDENTIFIER"); + + b.Property("TenantId") + .HasColumnType("UNIQUEIDENTIFIER"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.HasIndex("TenantId", "OwnerId") + .IsUnique(); + + b.ToTable("Actors", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.Audio.TextAudioEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("AudioBytes") + .HasColumnType("VARBINARY(MAX)"); + + b.Property("AudioUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("ActorId"); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.ToTable("TextAudio", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.ChatCompletion.ChatMessageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ChatSessionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("ChatSessionId"); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.ToTable("ChatMessages", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.ChatCompletion.ChatSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("ActorId"); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.ToTable("ChatSessions", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.Image.TextImageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Height") + .HasColumnType("int"); + + b.Property("ImageBytes") + .HasColumnType("VARBINARY(MAX)"); + + b.Property("ImageUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("Width") + .HasColumnType("int"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("ActorId"); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.ToTable("TextImages", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.TextGeneration.TextPromptEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActorId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("ActorId"); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.ToTable("TextPrompts", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.TextGeneration.TextResponseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("DeletedOn") + .HasColumnType("datetime2"); + + b.Property("ModifiedOn") + .HasColumnType("datetime2"); + + b.Property("Response") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TextPromptId") + .HasColumnType("uniqueidentifier"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + SqlServerKeyBuilderExtensions.IsClustered(b.HasKey("Id"), false); + + b.HasIndex("Id") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Id"), false); + + b.HasIndex("TextPromptId"); + + b.HasIndex("Timestamp") + .IsUnique(); + + SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("Timestamp")); + + b.ToTable("TextResponses", (string)null); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.Audio.TextAudioEntity", b => + { + b.HasOne("Goodtocode.AgentFramework.Core.Domain.Actor.ActorEntity", "Actor") + .WithMany() + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Actor"); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.ChatCompletion.ChatMessageEntity", b => + { + b.HasOne("Goodtocode.AgentFramework.Core.Domain.ChatCompletion.ChatSessionEntity", "ChatSession") + .WithMany("Messages") + .HasForeignKey("ChatSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatSession"); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.ChatCompletion.ChatSessionEntity", b => + { + b.HasOne("Goodtocode.AgentFramework.Core.Domain.Actor.ActorEntity", "Actor") + .WithMany() + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Actor"); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.Image.TextImageEntity", b => + { + b.HasOne("Goodtocode.AgentFramework.Core.Domain.Actor.ActorEntity", "Actor") + .WithMany() + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Actor"); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.TextGeneration.TextPromptEntity", b => + { + b.HasOne("Goodtocode.AgentFramework.Core.Domain.Actor.ActorEntity", "Actor") + .WithMany() + .HasForeignKey("ActorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Actor"); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.TextGeneration.TextResponseEntity", b => + { + b.HasOne("Goodtocode.AgentFramework.Core.Domain.TextGeneration.TextPromptEntity", "TextPrompt") + .WithMany() + .HasForeignKey("TextPromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TextPrompt"); + }); + + modelBuilder.Entity("Goodtocode.AgentFramework.Core.Domain.ChatCompletion.ChatSessionEntity", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure.SqlServer/Persistence/Configurations/ActorsConfig.cs b/src/Infrastructure.SqlServer/Persistence/Configurations/ActorsConfig.cs new file mode 100644 index 0000000..21bffb4 --- /dev/null +++ b/src/Infrastructure.SqlServer/Persistence/Configurations/ActorsConfig.cs @@ -0,0 +1,49 @@ +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence.Configurations; + +public class ActorsConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("Actors"); + + builder.HasKey(x => x.Id) + .IsClustered(false); + + builder.HasIndex(x => x.Id) + .IsClustered(false) + .IsUnique(); + + builder.HasIndex(x => x.Timestamp) + .IsClustered() + .IsUnique(); + + builder.Property(x => x.Id) + .ValueGeneratedOnAdd(); + + builder.Ignore(x => x.PartitionKey); + + builder.Property(x => x.FirstName) + .HasColumnType(ColumnTypes.Nvarchar200); + + builder.Property(x => x.LastName) + .HasColumnType(ColumnTypes.Nvarchar200); + + builder.Property(x => x.Email) + .HasColumnType(ColumnTypes.Nvarchar200); + + builder.Property(x => x.OwnerId) + .HasColumnType(ColumnTypes.Uniqueidentifier) + .IsRequired(); + + builder.Property(x => x.TenantId) + .HasColumnType(ColumnTypes.Uniqueidentifier) + .IsRequired(); + + builder.HasIndex(x => new { x.TenantId, x.OwnerId }) + .IsUnique(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.SqlServer/Persistence/Configurations/ChatMessagesConfig.cs b/src/Infrastructure.SqlServer/Persistence/Configurations/ChatMessagesConfig.cs new file mode 100644 index 0000000..a8e557d --- /dev/null +++ b/src/Infrastructure.SqlServer/Persistence/Configurations/ChatMessagesConfig.cs @@ -0,0 +1,24 @@ +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence.Configurations; + +public class ChatMessagesConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("ChatMessages"); + builder.HasKey(x => x.Id) + .IsClustered(false); + builder.HasIndex(x => x.Id) + .IsClustered(false) + .IsUnique(); + builder.HasIndex(x => x.Timestamp) + .IsClustered() + .IsUnique(); + builder.Property(x => x.Id) + .ValueGeneratedOnAdd(); + builder.Ignore(x => x.PartitionKey); + } +} \ No newline at end of file diff --git a/src/Infrastructure.SqlServer/Persistence/Configurations/ChatSessionsConfig.cs b/src/Infrastructure.SqlServer/Persistence/Configurations/ChatSessionsConfig.cs new file mode 100644 index 0000000..0013997 --- /dev/null +++ b/src/Infrastructure.SqlServer/Persistence/Configurations/ChatSessionsConfig.cs @@ -0,0 +1,28 @@ +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence.Configurations; + +public class ChatSessionsConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("ChatSessions"); + builder.HasKey(x => x.Id) + .IsClustered(false); + builder.HasIndex(x => x.Id) + .IsClustered(false) + .IsUnique(); + builder.HasIndex(x => x.Timestamp) + .IsClustered() + .IsUnique(); + builder.Property(x => x.Id) + .ValueGeneratedOnAdd(); + builder.Ignore(x => x.PartitionKey); + builder + .HasMany(cs => cs.Messages) + .WithOne(cm => cm.ChatSession) + .HasForeignKey(cm => cm.ChatSessionId); + } +} \ No newline at end of file diff --git a/src/Infrastructure.SqlServer/Persistence/Configurations/TextAudioConfig.cs b/src/Infrastructure.SqlServer/Persistence/Configurations/TextAudioConfig.cs new file mode 100644 index 0000000..2973aea --- /dev/null +++ b/src/Infrastructure.SqlServer/Persistence/Configurations/TextAudioConfig.cs @@ -0,0 +1,29 @@ +using Goodtocode.AgentFramework.Core.Domain.Audio; + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence.Configurations; + +public class TextAudioConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("TextAudio"); + builder.HasKey(x => x.Id) + .IsClustered(false); + builder.HasIndex(x => x.Id) + .IsClustered(false) + .IsUnique(); + builder.HasIndex(x => x.Timestamp) + .IsClustered() + .IsUnique(); + builder.Property(x => x.Id) + .ValueGeneratedOnAdd(); + builder.Ignore(x => x.PartitionKey); + builder.Property(x => x.AudioBytes) + .HasColumnType(ColumnTypes.VarbinaryMax) + .HasConversion( + v => v.HasValue ? v.Value.ToArray() : null, + v => v != null ? new ReadOnlyMemory(v) : null); + } +} \ No newline at end of file diff --git a/src/Infrastructure.SqlServer/Persistence/Configurations/TextImagesConfig.cs b/src/Infrastructure.SqlServer/Persistence/Configurations/TextImagesConfig.cs new file mode 100644 index 0000000..07b3fc4 --- /dev/null +++ b/src/Infrastructure.SqlServer/Persistence/Configurations/TextImagesConfig.cs @@ -0,0 +1,29 @@ +using Goodtocode.AgentFramework.Core.Domain.Image; + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence.Configurations; + +public class TextImagesConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("TextImages"); + builder.HasKey(x => x.Id) + .IsClustered(false); + builder.HasIndex(x => x.Id) + .IsClustered(false) + .IsUnique(); + builder.HasIndex(x => x.Timestamp) + .IsClustered() + .IsUnique(); + builder.Property(x => x.Id) + .ValueGeneratedOnAdd(); + builder.Ignore(x => x.PartitionKey); + builder.Property(x => x.ImageBytes) + .HasColumnType(ColumnTypes.VarbinaryMax) + .HasConversion( + v => v.HasValue ? v.Value.ToArray() : null, + v => v != null ? new ReadOnlyMemory(v) : null); + } +} \ No newline at end of file diff --git a/src/Infrastructure.SqlServer/Persistence/Configurations/TextPromptsConfig.cs b/src/Infrastructure.SqlServer/Persistence/Configurations/TextPromptsConfig.cs new file mode 100644 index 0000000..68b1b08 --- /dev/null +++ b/src/Infrastructure.SqlServer/Persistence/Configurations/TextPromptsConfig.cs @@ -0,0 +1,24 @@ +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence.Configurations; + +public class TextPromptsConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("TextPrompts"); + builder.HasKey(x => x.Id) + .IsClustered(false); + builder.HasIndex(x => x.Id) + .IsClustered(false) + .IsUnique(); + builder.HasIndex(x => x.Timestamp) + .IsClustered() + .IsUnique(); + builder.Property(x => x.Id) + .ValueGeneratedOnAdd(); + builder.Ignore(x => x.PartitionKey); + } +} \ No newline at end of file diff --git a/src/Infrastructure.SqlServer/Persistence/Configurations/TextResponsesConfig.cs b/src/Infrastructure.SqlServer/Persistence/Configurations/TextResponsesConfig.cs new file mode 100644 index 0000000..af7e42e --- /dev/null +++ b/src/Infrastructure.SqlServer/Persistence/Configurations/TextResponsesConfig.cs @@ -0,0 +1,24 @@ +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence.Configurations; + +public class TextResponsesConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("TextResponses"); + builder.HasKey(x => x.Id) + .IsClustered(false); + builder.HasIndex(x => x.Id) + .IsClustered(false) + .IsUnique(); + builder.HasIndex(x => x.Timestamp) + .IsClustered() + .IsUnique(); + builder.Property(x => x.Id) + .ValueGeneratedOnAdd(); + builder.Ignore(x => x.PartitionKey); + } +} \ No newline at end of file diff --git a/src/Infrastructure.SqlServer/Persistence/SemanticKernelContext.cs b/src/Infrastructure.SqlServer/Persistence/SemanticKernelContext.cs new file mode 100644 index 0000000..9a0dd89 --- /dev/null +++ b/src/Infrastructure.SqlServer/Persistence/SemanticKernelContext.cs @@ -0,0 +1,79 @@ +using Goodtocode.Domain.Entities; +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Domain.Actor; +using Goodtocode.AgentFramework.Core.Domain.Audio; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; +using Goodtocode.AgentFramework.Core.Domain.Image; +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; +using System.Reflection; + +namespace Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence; + +public class AgentFrameworkContext : DbContext, IAgentFrameworkContext +{ + public DbSet ChatMessages => Set(); + public DbSet ChatSessions => Set(); + public DbSet TextPrompts => Set(); + public DbSet TextResponses => Set(); + public DbSet TextImages => Set(); + public DbSet TextAudio => Set(); + public DbSet Actors => Set(); + + protected AgentFrameworkContext() { } + + public AgentFrameworkContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly(), + x => x.Namespace == $"{GetType().Namespace}.Configurations"); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + SetAuditFields(); + return base.SaveChangesAsync(cancellationToken); + } + + private void SetAuditFields() + { + var entries = ChangeTracker.Entries() + .Where(e => IsDomainEntity(e.Entity) && + (e.State == EntityState.Modified || e.State == EntityState.Added || e.State == EntityState.Deleted)); + + foreach (var entry in entries) + { + dynamic entity = entry.Entity; + if (entry.State == EntityState.Added) + { + entity.SetCreatedOn(DateTime.UtcNow); + entity.SetModifiedOn(null); + entity.SetDeletedOn(null); + } + else if (entry.State == EntityState.Modified) + { + entity.SetModifiedOn(DateTime.UtcNow); + entity.SetDeletedOn(null); + } + else if (entry.State == EntityState.Deleted) + { + entity.SetDeletedOn(DateTime.UtcNow); + entry.State = EntityState.Modified; + } + } + } + + private static bool IsDomainEntity(object entity) + { + var type = entity.GetType(); + while (type != null) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(DomainEntity<>)) + return true; + type = type.BaseType; + } + return false; + } +} diff --git a/src/Presentation.Blazor/Clients/.editorconfig b/src/Presentation.Blazor/Clients/.editorconfig new file mode 100644 index 0000000..df6a0b8 --- /dev/null +++ b/src/Presentation.Blazor/Clients/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*.g.cs] +# Allow the nswag generated files to have using directives inside the namespace +csharp_using_directive_placement = inside_namespace +dotnet_diagnostic.IDE0065.severity = none \ No newline at end of file diff --git a/src/Presentation.Blazor/Clients/BackendApiClient.g.cs b/src/Presentation.Blazor/Clients/BackendApiClient.g.cs new file mode 100644 index 0000000..3389a68 --- /dev/null +++ b/src/Presentation.Blazor/Clients/BackendApiClient.g.cs @@ -0,0 +1,4959 @@ +//---------------------- +// +// Generated using the NSwag toolchain v14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// +//---------------------- + +#pragma warning disable 108 // Disable "CS0108 '{derivedDto}.ToJson()' hides inherited member '{dtoBase}.ToJson()'. Use the new keyword if hiding was intended." +#pragma warning disable 114 // Disable "CS0114 '{derivedDto}.RaisePropertyChanged(String)' hides inherited member 'dtoBase.RaisePropertyChanged(String)'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword." +#pragma warning disable 472 // Disable "CS0472 The result of the expression is always 'false' since a value of type 'Int32' is never equal to 'null' of type 'Int32?' +#pragma warning disable 612 // Disable "CS0612 '...' is obsolete" +#pragma warning disable 649 // Disable "CS0649 Field is never assigned to, and will always have its default value null" +#pragma warning disable 1573 // Disable "CS1573 Parameter '...' has no matching param tag in the XML comment for ... +#pragma warning disable 1591 // Disable "CS1591 Missing XML comment for publicly visible type or member ..." +#pragma warning disable 8073 // Disable "CS8073 The result of the expression is always 'false' since a value of type 'T' is never equal to 'null' of type 'T?'" +#pragma warning disable 3016 // Disable "CS3016 Arrays as attribute arguments is not CLS-compliant" +#pragma warning disable 8600 // Disable "CS8600 Converting null literal or possible null value to non-nullable type" +#pragma warning disable 8602 // Disable "CS8602 Dereference of a possibly null reference" +#pragma warning disable 8603 // Disable "CS8603 Possible null reference return" +#pragma warning disable 8604 // Disable "CS8604 Possible null reference argument for parameter" +#pragma warning disable 8625 // Disable "CS8625 Cannot convert null literal to non-nullable reference type" +#pragma warning disable 8765 // Disable "CS8765 Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes)." + +namespace Goodtocode.AgentFramework.Presentation.WebApi.Client +{ + using System = global::System; + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class BackendApiClient + { + #pragma warning disable 8618 + private string _baseUrl; + #pragma warning restore 8618 + + private System.Net.Http.HttpClient _httpClient; + private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true); + private System.Text.Json.JsonSerializerOptions _instanceSettings; + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public BackendApiClient(string baseUrl, System.Net.Http.HttpClient httpClient) + #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + BaseUrl = baseUrl; + _httpClient = httpClient; + Initialize(); + } + + private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() + { + var settings = new System.Text.Json.JsonSerializerOptions(); + UpdateJsonSerializerSettings(settings); + return settings; + } + + public string BaseUrl + { + get { return _baseUrl; } + set + { + _baseUrl = value; + if (!string.IsNullOrEmpty(_baseUrl) && !_baseUrl.EndsWith("/")) + _baseUrl += '/'; + } + } + + protected System.Text.Json.JsonSerializerOptions JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } } + + static partial void UpdateJsonSerializerSettings(System.Text.Json.JsonSerializerOptions settings); + + partial void Initialize(); + + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url); + partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder); + partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response); + + /// + /// Get Text Audio with history + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetTextAudioQueryAsync(System.Guid id) + { + return GetTextAudioQueryAsync(id, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get Text Audio with history + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetTextAudioQueryAsync(System.Guid id, System.Threading.CancellationToken cancellationToken) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/audio/{id}" + urlBuilder_.Append("api/v1/admin/audio/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Remove Audio Command + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// No Content + /// A server side error occurred. + public virtual System.Threading.Tasks.Task RemoveTextAudioCommandAsync(System.Guid id) + { + return RemoveTextAudioCommandAsync(id, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Remove Audio Command + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// No Content + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RemoveTextAudioCommandAsync(System.Guid id, System.Threading.CancellationToken cancellationToken) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("DELETE"); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/audio/{id}" + urlBuilder_.Append("api/v1/admin/audio/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 204) + { + return; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get All Text Audios Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task> GetTextAudiosQueryAsync() + { + return GetTextAudiosQueryAsync(System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get All Text Audios Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> GetTextAudiosQueryAsync(System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/audio" + urlBuilder_.Append("api/v1/admin/audio"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Creates new Audio from Text + /// + /// + /// Sample request: + ///
+ ///
HttpPost Body + ///
{ + ///
"Id": 00000000-0000-0000-0000-000000000000, + ///
"Message": "Hi, I am interested in learning about Agent Framework." + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// Created + /// A server side error occurred. + public virtual System.Threading.Tasks.Task CreateTextToAudioCommandAsync(CreateTextToAudioCommand body) + { + return CreateTextToAudioCommandAsync(body, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Creates new Audio from Text + /// + /// + /// Sample request: + ///
+ ///
HttpPost Body + ///
{ + ///
"Id": 00000000-0000-0000-0000-000000000000, + ///
"Message": "Hi, I am interested in learning about Agent Framework." + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// Created + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task CreateTextToAudioCommandAsync(CreateTextToAudioCommand body, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/audio" + urlBuilder_.Append("api/v1/admin/audio"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 201) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Bad Request", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get Text Audios Paginated Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"PageNumber": 1 + ///
"PageSize" : 10 + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetTextAudioPaginatedQueryAsync(System.DateTimeOffset? startDate, System.DateTimeOffset? endDate, int? pageNumber, int? pageSize) + { + return GetTextAudioPaginatedQueryAsync(startDate, endDate, pageNumber, pageSize, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get Text Audios Paginated Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"PageNumber": 1 + ///
"PageSize" : 10 + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetTextAudioPaginatedQueryAsync(System.DateTimeOffset? startDate, System.DateTimeOffset? endDate, int? pageNumber, int? pageSize, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/audio/Paginated" + urlBuilder_.Append("api/v1/admin/audio/Paginated"); + urlBuilder_.Append('?'); + if (startDate != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("StartDate")).Append('=').Append(System.Uri.EscapeDataString(startDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (endDate != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("EndDate")).Append('=').Append(System.Uri.EscapeDataString(endDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageNumber != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageNumber")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageNumber, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageSize != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageSize")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageSize, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get Chat Message + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetChatMessageQueryAsync(System.Guid id) + { + return GetChatMessageQueryAsync(id, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get Chat Message + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetChatMessageQueryAsync(System.Guid id, System.Threading.CancellationToken cancellationToken) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/messages/{id}" + urlBuilder_.Append("api/v1/admin/messages/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get All Chat Messages for a session Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task> GetChatMessagesQueryAsync() + { + return GetChatMessagesQueryAsync(System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get All Chat Messages for a session Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> GetChatMessagesQueryAsync(System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/messages" + urlBuilder_.Append("api/v1/admin/messages"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Creates new Chat Message with initial message prompt/response history + /// + /// + /// Types of Chat Completion are: + ///
1. Informational Prompt: A prompt requesting information + ///
- Example Prompt: "What's the capital of France?" + ///
- Example Response: "The capital of France is Paris." + ///
2. Multiple Choice Prompt: A prompt with instructions for multiple-choice responses. + ///
- Example Prompt: “Choose an activity for the weekend: a) Hiking b) Movie night c) Cooking class d) Board games” + ///
- Example Response: “I'd recommend hiking! It's a great way to enjoy nature and get some exercise.” + ///
Sample request: + ///
+ ///
HttpPost Body + ///
{ + ///
"Id": 00000000-0000-0000-0000-000000000000, + ///
"Message": "Hi, I am interested in learning about Agent Framework." + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// Created + /// A server side error occurred. + public virtual System.Threading.Tasks.Task CreateChatMessageCommandAsync(CreateChatMessageCommand body) + { + return CreateChatMessageCommandAsync(body, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Creates new Chat Message with initial message prompt/response history + /// + /// + /// Types of Chat Completion are: + ///
1. Informational Prompt: A prompt requesting information + ///
- Example Prompt: "What's the capital of France?" + ///
- Example Response: "The capital of France is Paris." + ///
2. Multiple Choice Prompt: A prompt with instructions for multiple-choice responses. + ///
- Example Prompt: “Choose an activity for the weekend: a) Hiking b) Movie night c) Cooking class d) Board games” + ///
- Example Response: “I'd recommend hiking! It's a great way to enjoy nature and get some exercise.” + ///
Sample request: + ///
+ ///
HttpPost Body + ///
{ + ///
"Id": 00000000-0000-0000-0000-000000000000, + ///
"Message": "Hi, I am interested in learning about Agent Framework." + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// Created + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task CreateChatMessageCommandAsync(CreateChatMessageCommand body, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/messages" + urlBuilder_.Append("api/v1/admin/messages"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 201) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Bad Request", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get Chat Messages Paginated Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"PageNumber": 1 + ///
"PageSize" : 10 + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetChatMessagesPaginatedQueryAsync(System.DateTimeOffset? startDate, System.DateTimeOffset? endDate, int? pageNumber, int? pageSize) + { + return GetChatMessagesPaginatedQueryAsync(startDate, endDate, pageNumber, pageSize, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get Chat Messages Paginated Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"PageNumber": 1 + ///
"PageSize" : 10 + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetChatMessagesPaginatedQueryAsync(System.DateTimeOffset? startDate, System.DateTimeOffset? endDate, int? pageNumber, int? pageSize, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/messages/Paginated" + urlBuilder_.Append("api/v1/admin/messages/Paginated"); + urlBuilder_.Append('?'); + if (startDate != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("StartDate")).Append('=').Append(System.Uri.EscapeDataString(startDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (endDate != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("EndDate")).Append('=').Append(System.Uri.EscapeDataString(endDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageNumber != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageNumber")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageNumber, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageSize != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageSize")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageSize, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get Chat Session with history + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetChatSessionQueryAsync(System.Guid id) + { + return GetChatSessionQueryAsync(id, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get Chat Session with history + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetChatSessionQueryAsync(System.Guid id, System.Threading.CancellationToken cancellationToken) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/sessions/{id}" + urlBuilder_.Append("api/v1/admin/sessions/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Patch Chat Session Command + /// + /// + /// Sample request: + ///
+ ///
HttpPatch Body + ///
{ + ///
"Id": "60fb5e99-3a78-43df-a512-7d8ff498499e", + ///
"Title": "Agent Framework Chat Session" + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// No Content + /// A server side error occurred. + public virtual System.Threading.Tasks.Task PatchChatSessionCommandAsync(System.Guid id, PatchChatSessionCommand body) + { + return PatchChatSessionCommandAsync(id, body, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Patch Chat Session Command + /// + /// + /// Sample request: + ///
+ ///
HttpPatch Body + ///
{ + ///
"Id": "60fb5e99-3a78-43df-a512-7d8ff498499e", + ///
"Title": "Agent Framework Chat Session" + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// No Content + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task PatchChatSessionCommandAsync(System.Guid id, PatchChatSessionCommand body, System.Threading.CancellationToken cancellationToken) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("PATCH"); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/sessions/{id}" + urlBuilder_.Append("api/v1/admin/sessions/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 204) + { + return; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Remove ChatSession Command + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// No Content + /// A server side error occurred. + public virtual System.Threading.Tasks.Task RemoveChatSessionCommandAsync(System.Guid id) + { + return RemoveChatSessionCommandAsync(id, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Remove ChatSession Command + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// No Content + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RemoveChatSessionCommandAsync(System.Guid id, System.Threading.CancellationToken cancellationToken) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("DELETE"); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/sessions/{id}" + urlBuilder_.Append("api/v1/admin/sessions/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 204) + { + return; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get All Chat Sessions Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task> GetChatSessionsQueryAsync() + { + return GetChatSessionsQueryAsync(System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get All Chat Sessions Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> GetChatSessionsQueryAsync(System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/sessions" + urlBuilder_.Append("api/v1/admin/sessions"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Creates new Chat Session with initial message prompt/response history + /// + /// + /// Types of Chat Completion are: + ///
1. Informational Prompt: A prompt requesting information + ///
- Example Prompt: "What's the capital of France?" + ///
- Example Response: "The capital of France is Paris." + ///
2. Multiple Choice Prompt: A prompt with instructions for multiple-choice responses. + ///
- Example Prompt: “Choose an activity for the weekend: a) Hiking b) Movie night c) Cooking class d) Board games” + ///
- Example Response: “I'd recommend hiking! It's a great way to enjoy nature and get some exercise.” + ///
Sample request: + ///
+ ///
HttpPost Body + ///
{ + ///
"Id": 00000000-0000-0000-0000-000000000000, + ///
"Message": "Hi, I am interested in learning about Agent Framework." + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// Created + /// A server side error occurred. + public virtual System.Threading.Tasks.Task CreateChatSessionCommandAsync(CreateChatSessionCommand body) + { + return CreateChatSessionCommandAsync(body, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Creates new Chat Session with initial message prompt/response history + /// + /// + /// Types of Chat Completion are: + ///
1. Informational Prompt: A prompt requesting information + ///
- Example Prompt: "What's the capital of France?" + ///
- Example Response: "The capital of France is Paris." + ///
2. Multiple Choice Prompt: A prompt with instructions for multiple-choice responses. + ///
- Example Prompt: “Choose an activity for the weekend: a) Hiking b) Movie night c) Cooking class d) Board games” + ///
- Example Response: “I'd recommend hiking! It's a great way to enjoy nature and get some exercise.” + ///
Sample request: + ///
+ ///
HttpPost Body + ///
{ + ///
"Id": 00000000-0000-0000-0000-000000000000, + ///
"Message": "Hi, I am interested in learning about Agent Framework." + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// Created + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task CreateChatSessionCommandAsync(CreateChatSessionCommand body, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/sessions" + urlBuilder_.Append("api/v1/admin/sessions"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 201) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Bad Request", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Update ChatSession Command, typically with changing the title or adding a new message + /// + /// + /// Sample request: + ///
+ ///
HttpPut Body + ///
{ + ///
"Id": "60fb5e99-3a78-43df-a512-7d8ff498499e", + ///
"Message": "Hi, I am interested in learning about Agent Framework.", + ///
"Content": "Certainly! Agent Framework is a great framework for AI.", + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// No Content + /// A server side error occurred. + public virtual System.Threading.Tasks.Task UpdateChatSessionCommandAsync(UpdateChatSessionCommand body) + { + return UpdateChatSessionCommandAsync(body, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Update ChatSession Command, typically with changing the title or adding a new message + /// + /// + /// Sample request: + ///
+ ///
HttpPut Body + ///
{ + ///
"Id": "60fb5e99-3a78-43df-a512-7d8ff498499e", + ///
"Message": "Hi, I am interested in learning about Agent Framework.", + ///
"Content": "Certainly! Agent Framework is a great framework for AI.", + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// No Content + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task UpdateChatSessionCommandAsync(UpdateChatSessionCommand body, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("PUT"); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/sessions" + urlBuilder_.Append("api/v1/admin/sessions"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 204) + { + return; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get Chat Sessions Paginated Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"PageNumber": 1 + ///
"PageSize" : 10 + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetChatSessionsPaginatedQueryAsync(System.DateTimeOffset? startDate, System.DateTimeOffset? endDate, int? pageNumber, int? pageSize) + { + return GetChatSessionsPaginatedQueryAsync(startDate, endDate, pageNumber, pageSize, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get Chat Sessions Paginated Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"PageNumber": 1 + ///
"PageSize" : 10 + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetChatSessionsPaginatedQueryAsync(System.DateTimeOffset? startDate, System.DateTimeOffset? endDate, int? pageNumber, int? pageSize, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/sessions/Paginated" + urlBuilder_.Append("api/v1/admin/sessions/Paginated"); + urlBuilder_.Append('?'); + if (startDate != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("StartDate")).Append('=').Append(System.Uri.EscapeDataString(startDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (endDate != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("EndDate")).Append('=').Append(System.Uri.EscapeDataString(endDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageNumber != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageNumber")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageNumber, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageSize != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageSize")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageSize, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get Text Image with history + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetTextImageQueryAsync(System.Guid id) + { + return GetTextImageQueryAsync(id, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get Text Image with history + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetTextImageQueryAsync(System.Guid id, System.Threading.CancellationToken cancellationToken) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/images/{id}" + urlBuilder_.Append("api/v1/admin/images/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Remove Image Command + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// No Content + /// A server side error occurred. + public virtual System.Threading.Tasks.Task RemoveTextImageCommandAsync(System.Guid id) + { + return RemoveTextImageCommandAsync(id, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Remove Image Command + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// No Content + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RemoveTextImageCommandAsync(System.Guid id, System.Threading.CancellationToken cancellationToken) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("DELETE"); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/images/{id}" + urlBuilder_.Append("api/v1/admin/images/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 204) + { + return; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get All Text Images Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task> GetTextImagesQueryAsync() + { + return GetTextImagesQueryAsync(System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get All Text Images Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> GetTextImagesQueryAsync(System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/images" + urlBuilder_.Append("api/v1/admin/images"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Creates new Image from Text + /// + /// + /// Sample request: + ///
+ ///
HttpPost Body + ///
{ + ///
"Id": 00000000-0000-0000-0000-000000000000, + ///
"Message": "Hi, I am interested in learning about Agent Framework." + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// Created + /// A server side error occurred. + public virtual System.Threading.Tasks.Task CreateTextToImageCommandAsync(CreateTextToImageCommand body) + { + return CreateTextToImageCommandAsync(body, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Creates new Image from Text + /// + /// + /// Sample request: + ///
+ ///
HttpPost Body + ///
{ + ///
"Id": 00000000-0000-0000-0000-000000000000, + ///
"Message": "Hi, I am interested in learning about Agent Framework." + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// Created + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task CreateTextToImageCommandAsync(CreateTextToImageCommand body, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/images" + urlBuilder_.Append("api/v1/admin/images"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 201) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Bad Request", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get Text Images Paginated Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"PageNumber": 1 + ///
"PageSize" : 10 + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetTextImagesPaginatedQueryAsync(System.DateTimeOffset? startDate, System.DateTimeOffset? endDate, int? pageNumber, int? pageSize) + { + return GetTextImagesPaginatedQueryAsync(startDate, endDate, pageNumber, pageSize, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get Text Images Paginated Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"PageNumber": 1 + ///
"PageSize" : 10 + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetTextImagesPaginatedQueryAsync(System.DateTimeOffset? startDate, System.DateTimeOffset? endDate, int? pageNumber, int? pageSize, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/images/Paginated" + urlBuilder_.Append("api/v1/admin/images/Paginated"); + urlBuilder_.Append('?'); + if (startDate != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("StartDate")).Append('=').Append(System.Uri.EscapeDataString(startDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (endDate != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("EndDate")).Append('=').Append(System.Uri.EscapeDataString(endDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageNumber != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageNumber")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageNumber, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageSize != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageSize")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageSize, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get Text Generation session with history + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetTextPromptQueryAsync(System.Guid id) + { + return GetTextPromptQueryAsync(id, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get Text Generation session with history + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetTextPromptQueryAsync(System.Guid id, System.Threading.CancellationToken cancellationToken) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/text/{id}" + urlBuilder_.Append("api/v1/admin/text/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Remove TextGeneration Command + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// No Content + /// A server side error occurred. + public virtual System.Threading.Tasks.Task RemoveTextPromptCommandAsync(System.Guid id) + { + return RemoveTextPromptCommandAsync(id, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Remove TextGeneration Command + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// No Content + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task RemoveTextPromptCommandAsync(System.Guid id, System.Threading.CancellationToken cancellationToken) + { + if (id == null) + throw new System.ArgumentNullException("id"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("DELETE"); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/text/{id}" + urlBuilder_.Append("api/v1/admin/text/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 204) + { + return; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get All Text Generations Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task> GetTextPromptsQueryAsync() + { + return GetTextPromptsQueryAsync(System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get All Text Generations Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> GetTextPromptsQueryAsync(System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/text" + urlBuilder_.Append("api/v1/admin/text"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Creates new Text Generation session + /// + /// + /// Types of Text Generation are: + ///
1. Generic Prompt: A broad or open-ended request for content. + ///
- Example Prompt: “Write a short story.” + ///
- Example Response: “Once upon a time, in a quaint village, there lived a curious cat named Whiskers…” + ///
2. Specific Prompt: A prompt with clear instructions or a specific topic. + ///
- Example Prompt: “Describe the process of photosynthesis.” + ///
- Example Response: “Photosynthesis is the process by which plants convert sunlight into energy, using chlorophyll in their leaves…” + ///
3. Visual Prompt: A prompt related to an image or visual content. + ///
- Example Prompt: “Describe this image: ‘A serene sunset over the ocean.’” + ///
- Example Response: “The sun dipped below the horizon, casting hues of orange and pink across the calm waters…” + ///
4. Role-Based Prompt: A prompt where you assume a specific role or context. + ///
- Example Prompt: “Act as a travel guide.Describe the beauty of the Swiss Alps.” + ///
- Example Response: “Welcome to the majestic Swiss Alps! Snow-capped peaks, pristine lakes, and charming villages await…” + ///
5. Output Format Specification: A prompt specifying the desired output format. + ///
- Example Prompt: “Summarize the key findings of the research paper.” + ///
- Example Response: “The paper discusses novel algorithms for optimizing neural network training, achieving faster convergence…” + ///
Sample request: + ///
+ ///
HttpPost Body + ///
{ + ///
"Id": 00000000-0000-0000-0000-000000000000, + ///
"Message": "Hi, I am interested in learning about Agent Framework." + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// Created + /// A server side error occurred. + public virtual System.Threading.Tasks.Task CreateTextPromptCommandAsync(CreateTextPromptCommand body) + { + return CreateTextPromptCommandAsync(body, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Creates new Text Generation session + /// + /// + /// Types of Text Generation are: + ///
1. Generic Prompt: A broad or open-ended request for content. + ///
- Example Prompt: “Write a short story.” + ///
- Example Response: “Once upon a time, in a quaint village, there lived a curious cat named Whiskers…” + ///
2. Specific Prompt: A prompt with clear instructions or a specific topic. + ///
- Example Prompt: “Describe the process of photosynthesis.” + ///
- Example Response: “Photosynthesis is the process by which plants convert sunlight into energy, using chlorophyll in their leaves…” + ///
3. Visual Prompt: A prompt related to an image or visual content. + ///
- Example Prompt: “Describe this image: ‘A serene sunset over the ocean.’” + ///
- Example Response: “The sun dipped below the horizon, casting hues of orange and pink across the calm waters…” + ///
4. Role-Based Prompt: A prompt where you assume a specific role or context. + ///
- Example Prompt: “Act as a travel guide.Describe the beauty of the Swiss Alps.” + ///
- Example Response: “Welcome to the majestic Swiss Alps! Snow-capped peaks, pristine lakes, and charming villages await…” + ///
5. Output Format Specification: A prompt specifying the desired output format. + ///
- Example Prompt: “Summarize the key findings of the research paper.” + ///
- Example Response: “The paper discusses novel algorithms for optimizing neural network training, achieving faster convergence…” + ///
Sample request: + ///
+ ///
HttpPost Body + ///
{ + ///
"Id": 00000000-0000-0000-0000-000000000000, + ///
"Message": "Hi, I am interested in learning about Agent Framework." + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// Created + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task CreateTextPromptCommandAsync(CreateTextPromptCommand body, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/text" + urlBuilder_.Append("api/v1/admin/text"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 201) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Bad Request", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Get Text Generations Paginated Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"PageNumber": 1 + ///
"PageSize" : 10 + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetTextPromptsPaginatedQueryAsync(System.DateTimeOffset? startDate, System.DateTimeOffset? endDate, int? pageNumber, int? pageSize) + { + return GetTextPromptsPaginatedQueryAsync(startDate, endDate, pageNumber, pageSize, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Get Text Generations Paginated Query + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"PageNumber": 1 + ///
"PageSize" : 10 + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetTextPromptsPaginatedQueryAsync(System.DateTimeOffset? startDate, System.DateTimeOffset? endDate, int? pageNumber, int? pageSize, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/admin/text/Paginated" + urlBuilder_.Append("api/v1/admin/text/Paginated"); + urlBuilder_.Append('?'); + if (startDate != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("StartDate")).Append('=').Append(System.Uri.EscapeDataString(startDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (endDate != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("EndDate")).Append('=').Append(System.Uri.EscapeDataString(endDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageNumber != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageNumber")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageNumber, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageSize != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("PageSize")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageSize, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Retrieves the actor profile by external ID, including session history. + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetMyActorProfileAsync() + { + return GetMyActorProfileAsync(System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Retrieves the actor profile by external ID, including session history. + /// + /// + /// Sample request: + ///
+ ///
"Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetMyActorProfileAsync(System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1my/actors" + urlBuilder_.Append("api/v1my/actors"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Creates a new actor session with empty history. + /// + /// + /// Sample request: + ///
+ ///
HttpPost Body + ///
{ + ///
"Id": 00000000-0000-0000-0000-000000000000, + ///
"Name": "John Doe" + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// The command containing actor creation details. + /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task SaveMyActorAsync(SaveMyActorCommand body) + { + return SaveMyActorAsync(body, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Creates a new actor session with empty history. + /// + /// + /// Sample request: + ///
+ ///
HttpPost Body + ///
{ + ///
"Id": 00000000-0000-0000-0000-000000000000, + ///
"Name": "John Doe" + ///
} + ///
+ ///
"version": 1.0 + ///
+ /// The command containing actor creation details. + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task SaveMyActorAsync(SaveMyActorCommand body, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1my/actors" + urlBuilder_.Append("api/v1my/actors"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 201) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 400) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Bad Request", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Retrieves all chat sessions for the specified actor. + /// + /// + /// Sample request: + ///
+ ///
"ActorId": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"StartDate": "2024-06-01T00:00:00Z"s + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task> GetMyChatSessionsAsync() + { + return GetMyChatSessionsAsync(System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Retrieves all chat sessions for the specified actor. + /// + /// + /// Sample request: + ///
+ ///
"ActorId": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"StartDate": "2024-06-01T00:00:00Z"s + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"api-version": 1.0 + ///
+ /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task> GetMyChatSessionsAsync(System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/my/chat/ChatSessions" + urlBuilder_.Append("api/v1/my/chat/ChatSessions"); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Retrieves paginated chat sessions for the specified actor within an optional date range. + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"PageNumber": 1 + ///
"PageSize" : 10 + ///
"api-version": 1.0 + ///
+ /// The start date for filtering sessions (optional). + /// The end date for filtering sessions (optional). + /// The page number for pagination (default is 1). + /// The page size for pagination (default is 10). + /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetMyChatSessionsPaginatedAsync(System.DateTimeOffset? startDate, System.DateTimeOffset? endDate, int? pageNumber, int? pageSize) + { + return GetMyChatSessionsPaginatedAsync(startDate, endDate, pageNumber, pageSize, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Retrieves paginated chat sessions for the specified actor within an optional date range. + /// + /// + /// Sample request: + ///
+ ///
"StartDate": "2024-06-01T00:00:00Z" + ///
"EndDate": "2024-12-01T00:00:00Z" + ///
"PageNumber": 1 + ///
"PageSize" : 10 + ///
"api-version": 1.0 + ///
+ /// The start date for filtering sessions (optional). + /// The end date for filtering sessions (optional). + /// The page number for pagination (default is 1). + /// The page size for pagination (default is 10). + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetMyChatSessionsPaginatedAsync(System.DateTimeOffset? startDate, System.DateTimeOffset? endDate, int? pageNumber, int? pageSize, System.Threading.CancellationToken cancellationToken) + { + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/my/chat/Paginated" + urlBuilder_.Append("api/v1/my/chat/Paginated"); + urlBuilder_.Append('?'); + if (startDate != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("startDate")).Append('=').Append(System.Uri.EscapeDataString(startDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (endDate != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("endDate")).Append('=').Append(System.Uri.EscapeDataString(endDate.Value.ToString("s", System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageNumber != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("pageNumber")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageNumber, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (pageSize != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("pageSize")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(pageSize, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + /// + /// Retrieves a specific chat session for the actor by session ID. + /// + /// + /// Sample request: + ///
+ ///
"ActorId": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"ChatSessionId": 1efb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// The identifier of the chat session. + /// OK + /// A server side error occurred. + public virtual System.Threading.Tasks.Task GetMyChatSessionAsync(System.Guid chatSessionId) + { + return GetMyChatSessionAsync(chatSessionId, System.Threading.CancellationToken.None); + } + + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Retrieves a specific chat session for the actor by session ID. + /// + /// + /// Sample request: + ///
+ ///
"ActorId": 60fb5e99-3a78-43df-a512-7d8ff498499e + ///
"ChatSessionId": 1efb5e99-3a78-43df-a512-7d8ff498499e + ///
"api-version": 1.0 + ///
+ /// The identifier of the chat session. + /// OK + /// A server side error occurred. + public virtual async System.Threading.Tasks.Task GetMyChatSessionAsync(System.Guid chatSessionId, System.Threading.CancellationToken cancellationToken) + { + if (chatSessionId == null) + throw new System.ArgumentNullException("chatSessionId"); + + var client_ = _httpClient; + var disposeClient_ = false; + try + { + using (var request_ = new System.Net.Http.HttpRequestMessage()) + { + request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); + + var urlBuilder_ = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl); + // Operation Path: "api/v1/my/chat/{chatSessionId}" + urlBuilder_.Append("api/v1/my/chat/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(chatSessionId, System.Globalization.CultureInfo.InvariantCulture))); + + PrepareRequest(client_, request_, urlBuilder_); + + var url_ = urlBuilder_.ToString(); + request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + + PrepareRequest(client_, request_, url_); + + var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + var disposeResponse_ = true; + try + { + var headers_ = new System.Collections.Generic.Dictionary>(); + foreach (var item_ in response_.Headers) + headers_[item_.Key] = item_.Value; + if (response_.Content != null && response_.Content.Headers != null) + { + foreach (var item_ in response_.Content.Headers) + headers_[item_.Key] = item_.Value; + } + + ProcessResponse(client_, response_); + + var status_ = (int)response_.StatusCode; + if (status_ == 200) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + return objectResponse_.Object; + } + else + if (status_ == 401) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 404) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else + if (status_ == 500) + { + string responseText_ = ( response_.Content == null ) ? string.Empty : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); + throw new ApiException("Internal Server Error", status_, responseText_, headers_, null); + } + else + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new ApiException("Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + } + finally + { + if (disposeResponse_) + response_.Dispose(); + } + } + } + finally + { + if (disposeClient_) + client_.Dispose(); + } + } + + protected struct ObjectResponseResult + { + public ObjectResponseResult(T responseObject, string responseText) + { + this.Object = responseObject; + this.Text = responseText; + } + + public T Object { get; } + + public string Text { get; } + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } + + public bool ReadResponseAsString { get; set; } + + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) + { + return new ObjectResponseResult(default(T), string.Empty); + } + + if (ReadResponseAsString) + { + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try + { + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody, responseText); + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, responseText, headers, exception); + } + } + else + { + try + { + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) + { + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody, string.Empty); + } + } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new ApiException(message, (int)response.StatusCode, string.Empty, headers, exception); + } + } + } + + private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } + + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } + + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } + + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; + } + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ActorDto + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Name")] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("FirstName")] + public string FirstName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("LastName")] + public string LastName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Email")] + public string Email { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("OwnerId")] + public System.Guid OwnerId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TenantId")] + public System.Guid TenantId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("CreatedOn")] + public System.DateTimeOffset CreatedOn { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ModifiedOn")] + public System.DateTimeOffset? ModifiedOn { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("DeletedOn")] + public System.DateTimeOffset? DeletedOn { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ByteReadOnlyMemory + { + + [System.Text.Json.Serialization.JsonPropertyName("Length")] + public int Length { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("IsEmpty")] + public bool IsEmpty { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Span")] + public ByteReadOnlySpan Span { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ByteReadOnlySpan + { + + [System.Text.Json.Serialization.JsonPropertyName("Length")] + public int Length { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("IsEmpty")] + public bool IsEmpty { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ChatMessageDto + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ChatSessionId")] + public System.Guid ChatSessionId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Role")] + public string Role { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Content")] + public string Content { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Timestamp")] + public System.DateTimeOffset Timestamp { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ChatMessageDtoPaginatedList + { + + [System.Text.Json.Serialization.JsonPropertyName("Items")] + public System.Collections.Generic.ICollection Items { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("PageNumber")] + public int PageNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TotalPages")] + public int TotalPages { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TotalCount")] + public int TotalCount { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("HasPreviousPage")] + public bool HasPreviousPage { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("HasNextPage")] + public bool HasNextPage { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ChatSessionDto + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Title")] + public string Title { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ActorId")] + public System.Guid ActorId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Timestamp")] + public System.DateTimeOffset Timestamp { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Messages")] + public System.Collections.Generic.ICollection Messages { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ChatSessionDtoPaginatedList + { + + [System.Text.Json.Serialization.JsonPropertyName("Items")] + public System.Collections.Generic.ICollection Items { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("PageNumber")] + public int PageNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TotalPages")] + public int TotalPages { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TotalCount")] + public int TotalCount { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("HasPreviousPage")] + public bool HasPreviousPage { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("HasNextPage")] + public bool HasNextPage { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class CreateChatMessageCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ChatSessionId")] + public System.Guid ChatSessionId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Message")] + public string Message { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("UserInfo")] + public IUserEntity UserInfo { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class CreateChatSessionCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ActorId")] + public System.Guid ActorId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Title")] + public string Title { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Message")] + public string Message { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class CreateTextPromptCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Prompt")] + public string Prompt { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class CreateTextToAudioCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ActorId")] + public System.Guid ActorId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Prompt")] + public string Prompt { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class CreateTextToImageCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Prompt")] + public string Prompt { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Width")] + public int Width { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Height")] + public int Height { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class IUserEntity + { + + [System.Text.Json.Serialization.JsonPropertyName("OwnerId")] + public System.Guid OwnerId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TenantId")] + public System.Guid TenantId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("FirstName")] + public string FirstName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("LastName")] + public string LastName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Email")] + public string Email { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Roles")] + public System.Collections.Generic.ICollection Roles { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("CanDefine")] + public bool CanDefine { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("CanMonitor")] + public bool CanMonitor { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("CanClassify")] + public bool CanClassify { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("CanMitigate")] + public bool CanMitigate { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class PatchChatSessionCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Title")] + public string Title { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ProblemDetails + { + + [System.Text.Json.Serialization.JsonPropertyName("type")] + public string Type { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("title")] + public string Title { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("status")] + public int? Status { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("detail")] + public string Detail { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("instance")] + public string Instance { get; set; } + + private System.Collections.Generic.IDictionary _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class SaveMyActorCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("FirstName")] + public string FirstName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("LastName")] + public string LastName { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Email")] + public string Email { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TenantId")] + public System.Guid TenantId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("UserInfo")] + public IUserEntity UserInfo { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TextAudioDto + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ActorId")] + public System.Guid ActorId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Description")] + public string Description { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("AudioBytes")] + public ByteReadOnlyMemory AudioBytes { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("AudioUrl")] + public System.Uri AudioUrl { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Timestamp")] + public System.DateTimeOffset Timestamp { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TextAudioDtoPaginatedList + { + + [System.Text.Json.Serialization.JsonPropertyName("Items")] + public System.Collections.Generic.ICollection Items { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("PageNumber")] + public int PageNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TotalPages")] + public int TotalPages { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TotalCount")] + public int TotalCount { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("HasPreviousPage")] + public bool HasPreviousPage { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("HasNextPage")] + public bool HasNextPage { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TextImageDto + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ActorId")] + public System.Guid ActorId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Description")] + public string Description { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ImageBytes")] + public ByteReadOnlyMemory ImageBytes { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ImageUrl")] + public System.Uri ImageUrl { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Height")] + public int Height { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Width")] + public int Width { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Timestamp")] + public System.DateTimeOffset Timestamp { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TextImageDtoPaginatedList + { + + [System.Text.Json.Serialization.JsonPropertyName("Items")] + public System.Collections.Generic.ICollection Items { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("PageNumber")] + public int PageNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TotalPages")] + public int TotalPages { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TotalCount")] + public int TotalCount { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("HasPreviousPage")] + public bool HasPreviousPage { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("HasNextPage")] + public bool HasNextPage { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TextPromptDto + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("ActorId")] + public System.Guid ActorId { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Prompt")] + public string Prompt { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Timestamp")] + public System.DateTimeOffset Timestamp { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class TextPromptDtoPaginatedList + { + + [System.Text.Json.Serialization.JsonPropertyName("Items")] + public System.Collections.Generic.ICollection Items { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("PageNumber")] + public int PageNumber { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TotalPages")] + public int TotalPages { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("TotalCount")] + public int TotalCount { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("HasPreviousPage")] + public bool HasPreviousPage { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("HasNextPage")] + public bool HasNextPage { get; set; } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class UpdateChatSessionCommand + { + + [System.Text.Json.Serialization.JsonPropertyName("Id")] + public System.Guid Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Title")] + public string Title { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("Messages")] + public System.Collections.Generic.ICollection Messages { get; set; } + + } + + + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ApiException : System.Exception + { + public int StatusCode { get; private set; } + + public string Response { get; private set; } + + public System.Collections.Generic.IReadOnlyDictionary> Headers { get; private set; } + + public ApiException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Exception innerException) + : base(message + "\n\nStatus: " + statusCode + "\nResponse: \n" + ((response == null) ? "(null)" : response.Substring(0, response.Length >= 512 ? 512 : response.Length)), innerException) + { + StatusCode = statusCode; + Response = response; + Headers = headers; + } + + public override string ToString() + { + return string.Format("HTTP Response: \n\n{0}\n\n{1}", Response, base.ToString()); + } + } + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ApiException : ApiException + { + public TResult Result { get; private set; } + + public ApiException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, TResult result, System.Exception innerException) + : base(message, statusCode, response, headers, innerException) + { + Result = result; + } + } + +} + +#pragma warning restore 108 +#pragma warning restore 114 +#pragma warning restore 472 +#pragma warning restore 612 +#pragma warning restore 649 +#pragma warning restore 1573 +#pragma warning restore 1591 +#pragma warning restore 8073 +#pragma warning restore 3016 +#pragma warning restore 8600 +#pragma warning restore 8602 +#pragma warning restore 8603 +#pragma warning restore 8604 +#pragma warning restore 8625 +#pragma warning restore 8765 \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/Analytics/ClarityAnalytics.razor b/src/Presentation.Blazor/Components/Analytics/ClarityAnalytics.razor new file mode 100644 index 0000000..b03c4de --- /dev/null +++ b/src/Presentation.Blazor/Components/Analytics/ClarityAnalytics.razor @@ -0,0 +1,15 @@ +@code { + [Parameter] + public string ProjectId { get; set; } = string.Empty; +} + +@if (!string.IsNullOrWhiteSpace(ProjectId)) +{ + +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/App.razor b/src/Presentation.Blazor/Components/App.razor new file mode 100644 index 0000000..e8548f8 --- /dev/null +++ b/src/Presentation.Blazor/Components/App.razor @@ -0,0 +1,27 @@ +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Analytics + +@inject IConfiguration Configuration + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Presentation.Blazor/Components/Auth/Components/UserAuthMenu.razor b/src/Presentation.Blazor/Components/Auth/Components/UserAuthMenu.razor new file mode 100644 index 0000000..9973bac --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/Components/UserAuthMenu.razor @@ -0,0 +1,31 @@ +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Routing +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.FluentUI.AspNetCore.Components + +@inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider + + + + + Sign Out + + + + + Sign In + + + + +@code { + private void SignIn() + { + Navigation.NavigateTo(RouteConstants.SignIn, forceLoad: true); + } + + private void SignOut() + { + Navigation.NavigateTo(RouteConstants.SignOut, forceLoad: true); + } +} diff --git a/src/Presentation.Blazor/Components/Auth/Components/UserProfile.razor b/src/Presentation.Blazor/Components/Auth/Components/UserProfile.razor new file mode 100644 index 0000000..ebbdce6 --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/Components/UserProfile.razor @@ -0,0 +1,78 @@ +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Routing +@using Microsoft.AspNetCore.Components.Authorization + +@inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider + + + + + + + + + + + Sign out + + + + + + + + + Sign In + + + + +@code { + private string? UserDisplayName; + private string? UserImageUrl; + private const string DefaultImageUrl = "img/goodtocode-logo.png"; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + if (user.Identity?.IsAuthenticated == true) + { + UserDisplayName = user.FindFirst("name")?.Value; + UserImageUrl = user.FindFirst("picture")?.Value; + } + } + + private string GetUserImageUrl() + { + return string.IsNullOrWhiteSpace(UserImageUrl) ? DefaultImageUrl : UserImageUrl; + } + + private void SignOut() + { + Navigation.NavigateTo(RouteConstants.SignOut, forceLoad: true); + } + + private void ViewAccount() + { + Navigation.NavigateTo(RouteConstants.Account, forceLoad: true); + } + + private string GetInitials(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return string.Empty; + + var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 1) + return parts[0].Substring(0, 1).ToUpperInvariant(); + + return string.Concat(parts[0][0], parts[^1][0]).ToUpperInvariant(); + } +} diff --git a/src/Presentation.Blazor/Components/Auth/IUserClaimsInfo.cs b/src/Presentation.Blazor/Components/Auth/IUserClaimsInfo.cs new file mode 100644 index 0000000..56c8c41 --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/IUserClaimsInfo.cs @@ -0,0 +1,50 @@ +namespace Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth; + +/// +/// Represents a user's information, including identifiers, personal details, and contact information. +/// +/// This interface provides a standardized structure for accessing user-related data, such as unique +/// identifiers, name details, and email address. It is commonly used in scenarios where user identity and contact +/// information need to be retrieved or processed. +public interface IUserClaimsInfo +{ + /// + /// Gets the unique identifier for the object. + /// + Guid ObjectId { get; } + + /// + /// Gets the unique identifier of the tenant associated with the current context. + /// + Guid TenantId { get; } + + /// + /// Gets the first name of the individual. + /// + string Givenname { get; } + + /// + /// Gets the last name of the individual. + /// + string Surname { get; } + + /// + /// Gets the email address associated with the entity. + /// + string Email { get; } + + /// + /// Gets the highest role + /// + ICollection Roles { get; } + + /// + /// Gets the collection of scopes associated with the current operation. + /// + ICollection Scopes { get; } + + /// + /// Gets the collection of group names associated with the current user. + /// + ICollection Groups { get; } +} diff --git a/src/Presentation.Blazor/Components/Auth/Middleware/DownstreamApiAccessTokenProvider.cs b/src/Presentation.Blazor/Components/Auth/Middleware/DownstreamApiAccessTokenProvider.cs new file mode 100644 index 0000000..89505e6 --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/Middleware/DownstreamApiAccessTokenProvider.cs @@ -0,0 +1,26 @@ +using Goodtocode.SecuredHttpClient.Middleware; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Identity.Web; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Middleware; + +public class DownstreamApiAccessTokenProvider(IHttpContextAccessor httpContextAccessor, ITokenAcquisition tokenAcquisition, IConfiguration configuration) : IAccessTokenProvider +{ + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + private readonly ITokenAcquisition _tokenAcquisition = tokenAcquisition; + private readonly IConfiguration _configuration = configuration; + + public async Task GetAccessTokenAsync() + { + var context = _httpContextAccessor.HttpContext; + if (!context?.User?.Identity?.IsAuthenticated == true) + return string.Empty; + + var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync([ + $"api://{_configuration["BackendApi:ClientId"] ?? Guid.Empty.ToString()}/.default" + ], user: context?.User); + + return accessToken ?? string.Empty; + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/Auth/Middleware/MsGraphAccessTokenProvider.cs b/src/Presentation.Blazor/Components/Auth/Middleware/MsGraphAccessTokenProvider.cs new file mode 100644 index 0000000..09b1257 --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/Middleware/MsGraphAccessTokenProvider.cs @@ -0,0 +1,21 @@ +using Goodtocode.SecuredHttpClient.Middleware; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Http; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Middleware; + +public class MsGraphAccessTokenProvider(IHttpContextAccessor httpContextAccessor) : IAccessTokenProvider +{ + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + + public async Task GetAccessTokenAsync() + { + var context = _httpContextAccessor.HttpContext; + if (context == null) + return string.Empty; + + var accessToken = await context.GetTokenAsync(OpenIdConnectDefaults.AuthenticationScheme, "access_token"); + return accessToken ?? string.Empty; + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/Auth/Routing/LoginLogoutEndpointRouteBuilderExtensions.cs b/src/Presentation.Blazor/Components/Auth/Routing/LoginLogoutEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..4bfb911 --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/Routing/LoginLogoutEndpointRouteBuilderExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Routing; + +public static class LoginLogoutEndpointRouteBuilderExtensions +{ + public static IEndpointConventionBuilder MapSignInSignOut(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup(string.Empty); + + group.MapGet("/SignIn", (string? returnUrl) => TypedResults.Challenge(GetAuthProperties(returnUrl))) + .AllowAnonymous(); + + group.MapGet("/SignOut", (string? returnUrl) => TypedResults.SignOut(GetAuthProperties(returnUrl), + [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme])); + + return group; + } + + private static AuthenticationProperties GetAuthProperties(string? returnUrl) => + new() + { + RedirectUri = returnUrl switch + { + string => new Uri(returnUrl, UriKind.Absolute).PathAndQuery, + null => "/", + } + }; +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/Auth/Routing/RedirectToAccessDenied.razor b/src/Presentation.Blazor/Components/Auth/Routing/RedirectToAccessDenied.razor new file mode 100644 index 0000000..ccad79a --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/Routing/RedirectToAccessDenied.razor @@ -0,0 +1,11 @@ +@inject NavigationManager Navigation + +Access Denied +Redirecting to sign-in...> + +@code { + protected override void OnInitialized() + { + Navigation.NavigateTo(RouteConstants.SignIn, forceLoad: true); + } +} diff --git a/src/Presentation.Blazor/Components/Auth/Routing/RedirectToResetPassword.razor b/src/Presentation.Blazor/Components/Auth/Routing/RedirectToResetPassword.razor new file mode 100644 index 0000000..b099506 --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/Routing/RedirectToResetPassword.razor @@ -0,0 +1,10 @@ +@inject NavigationManager Navigation + +Redirecting to password reset...> + +@code { + protected override void OnInitialized() + { + Navigation.NavigateTo("https://goodtocodesecuredev.ciamlogin.com/19800ad2-82e2-4946-abd5-c87ba78a7224/oauth2/v2.0/authorize?p=B2C_1A_PasswordReset", forceLoad: true); + } +} diff --git a/src/Presentation.Blazor/Components/Auth/Routing/RedirectToSignIn.razor b/src/Presentation.Blazor/Components/Auth/Routing/RedirectToSignIn.razor new file mode 100644 index 0000000..2d21ec2 --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/Routing/RedirectToSignIn.razor @@ -0,0 +1,10 @@ +@inject NavigationManager Navigation + +Redirecting to sign-in... + +@code { + protected override void OnInitialized() + { + Navigation.NavigateTo(RouteConstants.SignIn, forceLoad: true); + } +} diff --git a/src/Presentation.Blazor/Components/Auth/Routing/RedirectToSignOut.razor b/src/Presentation.Blazor/Components/Auth/Routing/RedirectToSignOut.razor new file mode 100644 index 0000000..beb3615 --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/Routing/RedirectToSignOut.razor @@ -0,0 +1,12 @@ +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Routing + +@inject NavigationManager Navigation + +Signing out... + +@code { + protected override void OnInitialized() + { + Navigation.NavigateTo(RouteConstants.SignOut, forceLoad: true); + } +} diff --git a/src/Presentation.Blazor/Components/Auth/Routing/RouteConstants.cs b/src/Presentation.Blazor/Components/Auth/Routing/RouteConstants.cs new file mode 100644 index 0000000..657e732 --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/Routing/RouteConstants.cs @@ -0,0 +1,12 @@ +namespace Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Routing; + +public partial struct RouteConstants +{ + public const string Route = "authentication"; + public const string SignIn = "authentication/SignIn"; + public const string SignOut = "authentication/SignOut"; + public const string SignUp = "authentication/SignUp"; + public const string Account = "authentication/EditProfile"; + public const string ChangePassword = "authentication/ChangePassword"; + public const string ForgotPassword = "authentication/ForgotPassword"; +} diff --git a/src/Presentation.Blazor/Components/Auth/Services/IUserSyncService.cs b/src/Presentation.Blazor/Components/Auth/Services/IUserSyncService.cs new file mode 100644 index 0000000..8e8a40c --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/Services/IUserSyncService.cs @@ -0,0 +1,9 @@ +using System.Security.Claims; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Services; + +public interface IUserSyncService +{ + void UserChanged(ClaimsPrincipal? user); + Task SyncUserAsync(ClaimsPrincipal? user); +} diff --git a/src/Presentation.Blazor/Components/Auth/UserClaimsInfo.cs b/src/Presentation.Blazor/Components/Auth/UserClaimsInfo.cs new file mode 100644 index 0000000..c9b302c --- /dev/null +++ b/src/Presentation.Blazor/Components/Auth/UserClaimsInfo.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Http; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth; + +/// +/// User information implementation that retrieves data from the current HTTP context. +/// +/// HttpContext containing claims +public class UserClaimsInfo(IHttpContextAccessor contextAccessor) : IUserClaimsInfo +{ + private readonly HttpContext? context = contextAccessor?.HttpContext; + + /// + /// Gets the unique identifier of the user object associated with the current context. + /// + /// This property retrieves the value of the "objectidentifier" claim from the current user's context. + /// Ensure that the claim is present and properly formatted as a GUID in the authentication token. + public Guid ObjectId => Guid.TryParse( + context?.User.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value, + out var objectId) ? objectId : Guid.Empty; + + /// + /// Gets the unique identifier of the tenant associated with the current user. + /// + /// The tenant ID is extracted from the user's claims using the claim type + /// "http://schemas.microsoft.com/identity/claims/tenantid". Ensure that the claim is present and valid in the + /// user's identity for this property to return a meaningful value. + public Guid TenantId => Guid.TryParse( + context?.User.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value, + out var tenantId) ? tenantId : Guid.Empty; + + /// + /// Gets the first name of the user based on the associated claims. + /// + public string Givenname => context?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname")?.Value ?? string.Empty; + + /// + /// Gets the last name of the current user based on their claims. + /// + public string Surname => context?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname")?.Value ?? string.Empty; + + /// + /// Gets the email address of the current user based on their User Principal Name (UPN) claim. + /// + public string Email => context?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn")?.Value ?? string.Empty; + + /// + /// Gets the collection of scopes associated with the current user. + /// + /// Scopes are typically used to define the permissions or access levels granted to the user. + /// This property retrieves the scopes from the user's claims, specifically from the claim with the type + /// "http://schemas.microsoft.com/identity/claims/scope". + public ICollection Scopes => context?.User.FindFirst("http://schemas.microsoft.com/identity/claims/scope")?.Value?.Split(' ').ToList() ?? []; + + /// + /// Gets the collection of roles associated with the current user. + /// + public ICollection Roles => context?.User.FindAll("http://schemas.microsoft.com/ws/2008/06/identity/claims/role").Select(c => c.Value).ToList() ?? []; + + /// + /// Gets the collection of group names associated with the current user. + /// + public ICollection Groups => context?.User.FindAll("groups").Select(c => c.Value).ToList() ?? []; +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/FormChangeType.cs b/src/Presentation.Blazor/Components/FormChangeType.cs new file mode 100644 index 0000000..6268b41 --- /dev/null +++ b/src/Presentation.Blazor/Components/FormChangeType.cs @@ -0,0 +1,8 @@ +namespace Goodtocode.AgentFramework.Presentation.Blazor.Components; + +public enum FormChangeType +{ + Created, + Updated, + Deleted +} diff --git a/src/Presentation.Blazor/Components/Icons/ArrowIcon.razor b/src/Presentation.Blazor/Components/Icons/ArrowIcon.razor new file mode 100644 index 0000000..41f5e47 --- /dev/null +++ b/src/Presentation.Blazor/Components/Icons/ArrowIcon.razor @@ -0,0 +1,15 @@ + + + + +@code { + [Parameter] public string Direction { get; set; } = "right"; + [Parameter] public string Size { get; set; } = "1em"; + + private string PathData => Direction switch + { + "left" => "M12 8H4M8 12l-4-4 4-4", + "right" => "M4 8h8M8 4l4 4-4 4", + _ => "M4 8h8M8 4l4 4-4 4" + }; +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/Layout/Error.razor b/src/Presentation.Blazor/Components/Layout/Error.razor new file mode 100644 index 0000000..91db7df --- /dev/null +++ b/src/Presentation.Blazor/Components/Layout/Error.razor @@ -0,0 +1,40 @@ +@page "/Error" +@using System.Diagnostics + +Error + + + + Error. + An error occurred while processing your request. + + @if (ShowRequestId) + { + + Request ID: @RequestId + + } + + Development Mode + + Swapping to Development environment will display more detailed information about the error that occurred. + + + The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users.
+ For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +
+
+
+ +@code { + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/src/Presentation.Blazor/Components/Layout/MainLayout.razor b/src/Presentation.Blazor/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..5055454 --- /dev/null +++ b/src/Presentation.Blazor/Components/Layout/MainLayout.razor @@ -0,0 +1,92 @@ +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Components +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Routing +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Services +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.Identity.Web + +@inherits LayoutComponentBase +@inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager Navigation +@inject IUserSyncService UserSyncService + + +
+ + + Cloud in a Can + + + Microsoft Agent Framework Quick-start + + + +
+ + + + + @Body + + + + + + + + Oops! Something went wrong. + + + @ex.Message + + + + Go Home + + + + + + + + + + Home + + Chat Session + + +
+ +@code { + private ErrorBoundary? errorBoundaryRef; + + private void OnRecoverAndNavigate(Exception ex) + { + errorBoundaryRef?.Recover(); + Navigation.NavigateTo("/", forceLoad: true); + } + + private bool _synced = false; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + try + { + if (firstRender && !_synced) + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + if (user.Identity?.IsAuthenticated == true) + { + await UserSyncService.SyncUserAsync(user); + _synced = true; + } + } + } + catch (MicrosoftIdentityWebChallengeUserException) + { + Navigation.NavigateTo(RouteConstants.SignIn, forceLoad: true); + } + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/Layout/NotFound.razor b/src/Presentation.Blazor/Components/Layout/NotFound.razor new file mode 100644 index 0000000..5e15fcd --- /dev/null +++ b/src/Presentation.Blazor/Components/Layout/NotFound.razor @@ -0,0 +1,26 @@ +@page "/NotFound" + +
diff --git a/src/Presentation.Blazor/Components/Routes.razor b/src/Presentation.Blazor/Components/Routes.razor new file mode 100644 index 0000000..4f001ae --- /dev/null +++ b/src/Presentation.Blazor/Components/Routes.razor @@ -0,0 +1,27 @@ +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Routing +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Layout +@using Microsoft.AspNetCore.Components.Authorization + + + + + + + @if (!(context.User.Identity?.IsAuthenticated ?? false)) + { + + } + else + { + + } + + + + + + + + + + diff --git a/src/Presentation.Blazor/Components/Skeleton/SkeletonList.razor b/src/Presentation.Blazor/Components/Skeleton/SkeletonList.razor new file mode 100644 index 0000000..b8fc5d9 --- /dev/null +++ b/src/Presentation.Blazor/Components/Skeleton/SkeletonList.razor @@ -0,0 +1,50 @@ +@if (!_isTimeoutReached) +{ + + + + + + +} + +@code { + [Parameter] + public int Timeout { get; set; } = 0; + [Parameter] + public string Class { get; set; } = string.Empty; + [Parameter] + public string Style { get; set; } = string.Empty; + + private bool _isTimeoutReached = false; + private CancellationTokenSource? _cts; + + protected override async Task OnParametersSetAsync() + { + if (Timeout > 0) + { + _cts?.Cancel(); + _cts = new CancellationTokenSource(); + try + { + await Task.Delay(Timeout, _cts.Token); + _isTimeoutReached = true; + StateHasChanged(); + } + catch (TaskCanceledException) + { + // Ignore cancellation + } + } + else + { + _isTimeoutReached = false; + } + } + + public void Dispose() + { + _cts?.Cancel(); + _cts?.Dispose(); + } +} diff --git a/src/Presentation.Blazor/Components/Skeleton/SkeletonTable.razor b/src/Presentation.Blazor/Components/Skeleton/SkeletonTable.razor new file mode 100644 index 0000000..a2078e6 --- /dev/null +++ b/src/Presentation.Blazor/Components/Skeleton/SkeletonTable.razor @@ -0,0 +1,53 @@ +@if (!_isTimeoutReached) +{ + + + + + + + + + +} + +@code { + [Parameter] + public int Timeout { get; set; } = 0; + [Parameter] + public string Class { get; set; } = string.Empty; + [Parameter] + public string Style { get; set; } = string.Empty; + + private bool _isTimeoutReached = false; + private CancellationTokenSource? _cts; + + protected override async Task OnParametersSetAsync() + { + if (Timeout > 0) + { + _cts?.Cancel(); + _cts = new CancellationTokenSource(); + try + { + await Task.Delay(Timeout, _cts.Token); + _isTimeoutReached = true; + StateHasChanged(); + } + catch (TaskCanceledException) + { + // Ignore cancellation + } + } + else + { + _isTimeoutReached = false; + } + } + + public void Dispose() + { + _cts?.Cancel(); + _cts?.Dispose(); + } +} diff --git a/src/Presentation.Blazor/Components/Typography/H1Label.razor b/src/Presentation.Blazor/Components/Typography/H1Label.razor new file mode 100644 index 0000000..8b8d452 --- /dev/null +++ b/src/Presentation.Blazor/Components/Typography/H1Label.razor @@ -0,0 +1,17 @@ + + @ChildContent + + + + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string? Class { get; set; } = "h1-label"; + [Parameter] public string? Style { get; set; } = string.Empty; +} diff --git a/src/Presentation.Blazor/Components/Typography/H2Label.razor b/src/Presentation.Blazor/Components/Typography/H2Label.razor new file mode 100644 index 0000000..adf690b --- /dev/null +++ b/src/Presentation.Blazor/Components/Typography/H2Label.razor @@ -0,0 +1,17 @@ + + @ChildContent + + + + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string? Class { get; set; } = "h2-label"; + [Parameter] public string? Style { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/Typography/H3Label.razor b/src/Presentation.Blazor/Components/Typography/H3Label.razor new file mode 100644 index 0000000..114aa5c --- /dev/null +++ b/src/Presentation.Blazor/Components/Typography/H3Label.razor @@ -0,0 +1,17 @@ + + @ChildContent + + + + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string? Class { get; set; } = "h3-label"; + [Parameter] public string? Style { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/Typography/PLabel.razor b/src/Presentation.Blazor/Components/Typography/PLabel.razor new file mode 100644 index 0000000..1e13377 --- /dev/null +++ b/src/Presentation.Blazor/Components/Typography/PLabel.razor @@ -0,0 +1,18 @@ + + @ChildContent + + + + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string? Class { get; set; } = "p-label"; + [Parameter] public string? Style { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/Wizard/WizardExample.razor b/src/Presentation.Blazor/Components/Wizard/WizardExample.razor new file mode 100644 index 0000000..b30d994 --- /dev/null +++ b/src/Presentation.Blazor/Components/Wizard/WizardExample.razor @@ -0,0 +1,20 @@ +@page "/wizard-example" + +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Web + +Your wizard of steps + + + +

Put your step 1 component here. Feel free to bind.

+
+ +

Put your step 2 component here. Feel free to bind.

+
+
+ +@code { + private WizardLayout? wizardLayout; + +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/Wizard/WizardLayout.razor b/src/Presentation.Blazor/Components/Wizard/WizardLayout.razor new file mode 100644 index 0000000..73ff2f5 --- /dev/null +++ b/src/Presentation.Blazor/Components/Wizard/WizardLayout.razor @@ -0,0 +1,101 @@ +@using System.Collections.ObjectModel +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Icons + + +
+ @if (ShowBreadcrumbs) + { + + } + + @if (Steps.Count > 0 && CurrentStepIndex < Steps.Count) + { + @Steps[CurrentStepIndex].ChildContent + } + else + { + @ChildContent + } + +
+ + +
+
+
+ +@code { + [Parameter] public required RenderFragment ChildContent { get; set; } + [Parameter] public bool ShowBreadcrumbs { get; set; } = true; + + internal List Steps { get; set; } = new(); + internal int CurrentStepIndex { get; set; } = 0; + + public bool IsFirstStep => CurrentStepIndex == 0; + public bool IsLastStep => CurrentStepIndex >= Steps.Count - 1; + + public void RegisterStep(WizardStep step) + { + if (!Steps.Contains(step)) + { + Steps.Add(step); + StateHasChanged(); + } + } + + public void Next() + { + if (!IsLastStep) + { + CurrentStepIndex++; + StateHasChanged(); + } + } + + public void Back() + { + if (!IsFirstStep) + { + CurrentStepIndex--; + StateHasChanged(); + } + } + + public void GoToStep(int index) + { + if (index >= 0 && index < Steps.Count) + { + CurrentStepIndex = index; + StateHasChanged(); + } + } + + public bool IsActiveStep(WizardStep step) => Steps.IndexOf(step) == CurrentStepIndex; + + public WizardContext Context => new(this); + + public class WizardContext + { + private readonly WizardLayout _layout; + public WizardContext(WizardLayout layout) => _layout = layout; + public void RegisterStep(WizardStep step) => _layout.RegisterStep(step); + public void Next() => _layout.Next(); + public void Back() => _layout.Back(); + public int CurrentStep => _layout.CurrentStepIndex; + public IReadOnlyList Steps => _layout.Steps.AsReadOnly(); + public void GoToStep(int index) => _layout.GoToStep(index); + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/Wizard/WizardStep.razor b/src/Presentation.Blazor/Components/Wizard/WizardStep.razor new file mode 100644 index 0000000..82dea89 --- /dev/null +++ b/src/Presentation.Blazor/Components/Wizard/WizardStep.razor @@ -0,0 +1,27 @@ +@inherits ComponentBase + +@if (IsActiveStep()) +{ +
+ @ChildContent +
+} + +@code { + [CascadingParameter] public required WizardLayout.WizardContext Wizard { get; set; } + [Parameter] public required string Title { get; set; } + [Parameter] public required RenderFragment ChildContent { get; set; } + + protected override void OnInitialized() + { + Wizard?.RegisterStep(this); + } + + private bool IsActiveStep() + { + if (Wizard?.Steps == null) + return false; + int thisIndex = Wizard.Steps.ToList().IndexOf(this); + return Wizard.CurrentStep == thisIndex; + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Components/_Imports.razor b/src/Presentation.Blazor/Components/_Imports.razor new file mode 100644 index 0000000..9b1ccde --- /dev/null +++ b/src/Presentation.Blazor/Components/_Imports.razor @@ -0,0 +1,12 @@ +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.FluentUI.AspNetCore.Components +@using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons +@using Microsoft.JSInterop +@using System.ComponentModel.DataAnnotations +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Typography \ No newline at end of file diff --git a/src/Presentation.Blazor/ConfigureServices.cs b/src/Presentation.Blazor/ConfigureServices.cs new file mode 100644 index 0000000..3b968d9 --- /dev/null +++ b/src/Presentation.Blazor/ConfigureServices.cs @@ -0,0 +1,72 @@ +using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth; +using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Services; +using Goodtocode.AgentFramework.Presentation.Blazor.Options; +using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Services; +using Goodtocode.AgentFramework.Presentation.Blazor.Services; +using Goodtocode.AgentFramework.Presentation.WebApi.Client; +using Microsoft.Extensions.Options; +using Microsoft.FluentUI.AspNetCore.Components; +using System.Reflection; + +namespace Goodtocode.AgentFramework.Presentation.Blazor; + +public static class ConfigureServices +{ + + public static bool IsLocal(this IWebHostEnvironment environment) + { + return environment.EnvironmentName.Equals("Local", StringComparison.OrdinalIgnoreCase); + } + + public static void AddLocalEnvironment(this WebApplicationBuilder builder) + { + if (builder.Environment.IsLocal()) + { + builder.Configuration + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables(); + builder.WebHost.UseStaticWebAssets(); + } + } + + public static void AddFrontendServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + + public static IServiceCollection AddUserClaimsSyncService(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } + + public static IServiceCollection AddBackendApi(this IServiceCollection services, + IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(BackendApiOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddAccessTokenHttpClient(options => + { + options.BaseAddress = new Uri(configuration["BackendApi:BaseUrl"] ?? throw new InvalidOperationException("Base URL for BackEndApi is not configured.")); + options.ClientName = "BackendApiClient"; + options.MaxRetry = 3; + }); + + services.AddScoped(provider => + { + var options = provider.GetRequiredService>().Value; + var httpClientFactory = provider.GetRequiredService(); + var httpClient = httpClientFactory.CreateClient("BackendApiClient"); + return new BackendApiClient(options.BaseUrl.ToString(), httpClient); + }); + + return services; + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/ConfigureServicesAuth.cs b/src/Presentation.Blazor/ConfigureServicesAuth.cs new file mode 100644 index 0000000..3cef81c --- /dev/null +++ b/src/Presentation.Blazor/ConfigureServicesAuth.cs @@ -0,0 +1,149 @@ +using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Middleware; +using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Services; +using Goodtocode.AgentFramework.Presentation.Blazor.Options; +using Goodtocode.SecuredHttpClient.Middleware; +using Goodtocode.SecuredHttpClient.Options; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Identity.Web; + +namespace Goodtocode.AgentFramework.Presentation.Blazor; + +public static class ConfigureServicesAuth +{ + private struct TokenRoleClaimTypes + { + public const string Roles = "roles"; + public const string Groups = "groups"; + } + + public static void AddAuthenticationForDownstream(this IServiceCollection services, IConfiguration configuration) + { + services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApp(options => + { + configuration.GetSection("EntraExternalId").Bind(options); + options.SignInScheme = OpenIdConnectDefaults.AuthenticationScheme; + }) + .EnableTokenAcquisitionToCallDownstreamApi() + .AddInMemoryTokenCaches() + .AddDownstreamApi("BackendApi", configOptions => + { + configOptions.BaseUrl = configuration["BackendApi:BaseUrl"]; + configOptions.Scopes = [$"api://{configuration["BackendApi:ClientId"] ?? Guid.Empty.ToString()}/.default", + "User.Read"]; + }); + + services.Configure(OpenIdConnectDefaults.AuthenticationScheme, options => + { + options.SaveTokens = true; + options.Events = new OpenIdConnectEvents + { + OnTokenValidated = context => + { + var syncService = context.HttpContext.RequestServices.GetService(); + syncService?.UserChanged(context.Principal); + return Task.CompletedTask; + } + }; + }); + + ////For development: .AddInMemoryTokenCaches() + ////For Production: .AddDistributedTokenCaches() + ////services.AddDistributedMemoryCache(); + //services.Configure( + // options => + // { + // // Disable L1 Cache default: false + // //options.DisableL1Cache = false; + + // // L1 Cache Size Limit default: 500 MB + // //options.L1CacheOptions.SizeLimit = 500 * 1024 * 1024; + + // // Encrypt tokens at rest default: false + // options.Encrypt = true; + + // // Sliding Expiration default: 1 hour + // //options.SlidingExpiration = TimeSpan.FromHours(1); + // }); + + ///* When you move to web farm testing with tokens encrypted at rest while + // * hosting multiple app instances and stop using AddDistributedMemoryCache + // * in favor of a production distributed token cache provider, enable the + // * following code, which configures Data Protection to protect keys with + // * Azure Key Vault and maintain keys in Azure Blob Storage. + // * Our recommended approach for using Azure Blob Storage and Azure Key + // * Vault is to use an Azure Managed Identity in production. + // * Give the Managed Identity 'Key Vault Crypto User' and + // * 'Storage Blob Data Contributor' roles. Assign the Managed Identity + // * to the App Service in Settings > Identity > User assigned > Add. + // * Other options, both within Azure and outside of Azure, are available for + // * managing Data Protection keys across multiple app instances. See the + // * ASP.NET Core Data Protection documentation for details. + + //// Requires the Microsoft.Extensions.Azure NuGet package + //services.TryAddSingleton(); + + //TokenCredential? credential; + + //if (builder.Environment.IsProduction()) + //{ + // credential = new ManagedIdentityCredential("{MANAGED IDENTITY CLIENT ID}"); + //} + //else + //{ + // // Local development and testing only + // DefaultAzureCredentialOptions options = new() + // { + // // Specify the tenant ID to use the dev credentials when running the app locally + // // in Visual Studio. + // VisualStudioTenantId = "{TENANT ID}", + // SharedTokenCacheTenantId = "{TENANT ID}" + // }; + + // credential = new DefaultAzureCredential(options); + //} + + //services.AddDataProtection() + // .SetApplicationName("BlazorWebAppEntra") + // .PersistKeysToAzureBlobStorage(new Uri("{BLOB URI}"), credential) + // .ProtectKeysWithAzureKeyVault( new Uri("{KEY IDENTIFIER}"), credential); + //*/ + //// Add DI + ////services.AddScoped(); + //// Use + ////await tokenAcquisition.GetAccessTokenForUserAsync(new[] { "your-scope" }, OpenIdConnectDefaults.AuthenticationScheme); + } + + public static IServiceCollection AddAccessTokenHttpClient( + this IServiceCollection services, + Action configureOptions) + { + var options = new ResilientHttpClientOptions(); + configureOptions(options); + + if (options.BaseAddress == null) + throw new ArgumentNullException(nameof(configureOptions), "BaseAddress must be provided."); + if (string.IsNullOrWhiteSpace(options.ClientName)) + throw new ArgumentNullException(nameof(configureOptions), "ClientName must be provided."); + + services.AddOptions() + .ValidateDataAnnotations() + .ValidateOnStart(); + services.AddScoped(); + services.AddScoped(); + + services.AddHttpClient(options.ClientName, clientOptions => + { + clientOptions.DefaultRequestHeaders.Clear(); + clientOptions.BaseAddress = options.BaseAddress; + }) + .AddHttpMessageHandler() + .AddStandardResilienceHandler(resilienceOptions => + { + resilienceOptions.Retry.UseJitter = true; + resilienceOptions.Retry.MaxRetryAttempts = options.MaxRetry; + }); + + return services; + } +} diff --git a/src/Presentation.Blazor/Options/BackendApiOptions.cs b/src/Presentation.Blazor/Options/BackendApiOptions.cs new file mode 100644 index 0000000..1f7a166 --- /dev/null +++ b/src/Presentation.Blazor/Options/BackendApiOptions.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Options; + +/// +/// Presentation.WebApi settings +/// +public sealed class BackendApiOptions +{ + public const string SectionName = "BackendApi"; + + [Required] + public Uri BaseUrl { get; set; } = default!; +} diff --git a/src/Presentation.Blazor/Options/ResilientHttpClientOptions.cs b/src/Presentation.Blazor/Options/ResilientHttpClientOptions.cs new file mode 100644 index 0000000..f6eb21e --- /dev/null +++ b/src/Presentation.Blazor/Options/ResilientHttpClientOptions.cs @@ -0,0 +1,7 @@ +namespace Goodtocode.AgentFramework.Presentation.Blazor.Options; +public class ResilientHttpClientOptions +{ + public Uri? BaseAddress { get; set; } + public string? ClientName { get; set; } + public int MaxRetry { get; set; } = 5; +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Pages/Chat/Components/ChatMessageList.razor b/src/Presentation.Blazor/Pages/Chat/Components/ChatMessageList.razor new file mode 100644 index 0000000..4ee710f --- /dev/null +++ b/src/Presentation.Blazor/Pages/Chat/Components/ChatMessageList.razor @@ -0,0 +1,27 @@ +@using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Models +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.JSInterop + +@inject IJSRuntime JSRuntime + + + @foreach (var message in Messages ?? Enumerable.Empty()) + { + var isUser = IsUserMessage(message); + + + @message.Content + + + + } + + + +@code { + [Parameter] + public IEnumerable Messages { get; set; } = new List(); + + private bool IsUserMessage(ChatMessageModel message) => + message?.Role?.ToLowerInvariant() == "user"; +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Pages/Chat/Components/ChatSessionEditForm.razor b/src/Presentation.Blazor/Pages/Chat/Components/ChatSessionEditForm.razor new file mode 100644 index 0000000..4da7455 --- /dev/null +++ b/src/Presentation.Blazor/Pages/Chat/Components/ChatSessionEditForm.razor @@ -0,0 +1,42 @@ +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms + + + + + + + + + + +@code { + [Parameter] public string? Title { get; set; } + [Parameter] public EventCallback OnSave { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + + private EditSessionModel editModel = new(); + private EditContext? editContext; + + protected override void OnParametersSet() + { + editModel = new EditSessionModel { Title = Title }; + editContext = new EditContext(editModel); + } + + private async Task HandleValidSubmit() + { + if (editContext is null || !editContext.Validate()) + return; + await OnSave.InvokeAsync(editModel.Title!); + } + + public class EditSessionModel + { + [Required(ErrorMessage = "Title is required.")] + public string? Title { get; set; } + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Pages/Chat/Components/ChatSessionList.razor b/src/Presentation.Blazor/Pages/Chat/Components/ChatSessionList.razor new file mode 100644 index 0000000..3632b71 --- /dev/null +++ b/src/Presentation.Blazor/Pages/Chat/Components/ChatSessionList.razor @@ -0,0 +1,103 @@ +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Skeleton +@using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Models +@using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Services + +@inject IChatService chatService + +@if (Sessions?.Any() == true) +{ + + + @{ + var sessionItem = (ChatSessionTreeViewItem)item; + var session = sessionItem.Session; + var isSelected = (selectedTreeItem is ChatSessionTreeViewItem selected) + && (item is ChatSessionTreeViewItem current) + && selected.Session.Id == current.Session.Id; + } + @if (editingSessionId == session.Id) + { + + } + else if (isSelected) + { + + + @sessionItem.Text + + + + } + else + { + @sessionItem.Text + } + + +} +else +{ + +} + +@code { + [Parameter] public IEnumerable? Sessions { get; set; } + [Parameter] public EventCallback OnSessionSelected { get; set; } + [Parameter] public EventCallback OnRenameSession { get; set; } + + private IEnumerable? _sessionTreeItemsCache; + + private IEnumerable SessionTreeItems + => _sessionTreeItemsCache ??= Sessions?.Select(ChatSessionTreeViewItem.CreateFrom).Cast().ToList() + ?? Enumerable.Empty(); + + private Guid? editingSessionId; + private EditSessionModel editModel = new(); + private EditContext? editContext; + private ITreeViewItem? selectedTreeItem; + + private async Task OnTreeSessionSelected(object? selectedItem) + { + selectedTreeItem = selectedItem as ITreeViewItem; + if (selectedTreeItem is ChatSessionTreeViewItem item) + { + await OnSessionSelected.InvokeAsync(item.Session); + } + } + + private void StartEditing(ChatSessionModel session) + { + editingSessionId = session.Id; + editModel = new EditSessionModel { Title = session.Title }; + editContext = new EditContext(editModel); + } + + private async Task EditSessionTitleAsync(ChatSessionModel session, string title) + { + session.Title = title; + editingSessionId = null; + await chatService.RenameSessionAsync(session.Id, title); + await OnRenameSession.InvokeAsync(session); + } + + private void CancelEditing() => editingSessionId = null; + + public void ClearSelection() + { + selectedTreeItem = null; + StateHasChanged(); + } + + protected override void OnParametersSet() => _sessionTreeItemsCache = null; + + public class EditSessionModel + { + [Required(ErrorMessage = "Title is required.")] + public string? Title { get; set; } + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Pages/Chat/Components/ChatSessionScroll.razor b/src/Presentation.Blazor/Pages/Chat/Components/ChatSessionScroll.razor new file mode 100644 index 0000000..512c6bd --- /dev/null +++ b/src/Presentation.Blazor/Pages/Chat/Components/ChatSessionScroll.razor @@ -0,0 +1,85 @@ +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Skeleton +@using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Models +@using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Services + +@inject IChatService chatService + +@if (Sessions?.Any() == true) +{ + + @foreach (var session in Sessions) + { + + @if (editingSessionId == session.Id) + { + + } + else + { + + + @session.Title + + @if (selectedSession?.Id == session.Id) + { + + } + + } + + } + +} +else +{ + +} + +@code { + [Parameter] public IEnumerable? Sessions { get; set; } + [Parameter] public EventCallback OnSessionSelected { get; set; } + [Parameter] public EventCallback OnRenameSession { get; set; } + + private Guid? editingSessionId; + private EditSessionModel editModel = new(); + private EditContext? editContext; + private ChatSessionModel? selectedSession; + + private async Task SelectSession(ChatSessionModel session) + { + selectedSession = session; + await OnSessionSelected.InvokeAsync(session); + } + + private void StartEditing(ChatSessionModel session) + { + editingSessionId = session.Id; + editModel = new EditSessionModel { Title = session.Title }; + editContext = new EditContext(editModel); + } + + private async Task EditSessionTitleAsync(ChatSessionModel session, string title) + { + session.Title = title; + editingSessionId = null; + await chatService.RenameSessionAsync(session.Id, title); + await OnRenameSession.InvokeAsync(session); + } + + private void CancelEditing() => editingSessionId = null; + + public void ClearSelection() + { + selectedSession = null; + StateHasChanged(); + } + + public class EditSessionModel + { + [Required(ErrorMessage = "Title is required.")] + public string? Title { get; set; } + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Pages/Chat/Components/NewChatMessageCard.razor b/src/Presentation.Blazor/Pages/Chat/Components/NewChatMessageCard.razor new file mode 100644 index 0000000..d861f3f --- /dev/null +++ b/src/Presentation.Blazor/Pages/Chat/Components/NewChatMessageCard.razor @@ -0,0 +1,93 @@ +@using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Models +@using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Services + +@inject IChatService chatService + + + + + + + @if (!isSubmitting) + { + @("Send") + } + else + { + + } + + + + + + + +@code +{ + [Parameter] public string Height { get; set; } = "80px"; + [Parameter] public string Width { get; set; } = "70%"; + [Parameter] public ChatSessionsModel Sessions { get; set; } = new ChatSessionsModel(); + [Parameter] public EventCallback OnSessionCreated { get; set; } + [Parameter] public EventCallback OnMessageSubmitted { get; set; } + + public class ChatMessageInputModel + { + [Required(ErrorMessage = "Message is required.")] + public string NewMessage { get; set; } = string.Empty; + } + + private ChatMessageInputModel messageModel { get; set; } = new(); + private EditContext? editContext; + private bool isSubmitting = false; + + protected override void OnInitialized() + { + editContext = new EditContext(messageModel); + } + + private async Task SubmitMessage() + { + if (editContext is not null && editContext.Validate()) + { + isSubmitting = true; + StateHasChanged(); + try + { + var newMessageModel = new ChatMessageModel + { + Id = Guid.NewGuid(), + Role = "User", + Content = messageModel.NewMessage, + Timestamp = DateTimeOffset.Now + }; + if (Sessions.ActiveSession == null) + { + var chatSession = await chatService.CreateSessionAsync(messageModel.NewMessage); + await OnSessionCreated.InvokeAsync(chatSession); + } + else + { + newMessageModel.ChatSessionId = Sessions.ActiveSession.Id; + await chatService.SendMessageAsync(Sessions.ActiveSession!.Id, messageModel.NewMessage); + await OnMessageSubmitted.InvokeAsync(); + } + ClearForm(); + } + finally + { + isSubmitting = false; + StateHasChanged(); + } + } + } + + private void ClearForm() + { + messageModel.NewMessage = string.Empty; + editContext = new EditContext(messageModel); + } +} diff --git a/src/Presentation.Blazor/Pages/Chat/Components/NewChatMessageInput.razor b/src/Presentation.Blazor/Pages/Chat/Components/NewChatMessageInput.razor new file mode 100644 index 0000000..d9f3308 --- /dev/null +++ b/src/Presentation.Blazor/Pages/Chat/Components/NewChatMessageInput.razor @@ -0,0 +1,91 @@ +@using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Models +@using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Services + +@inject IChatService chatService + + + + + + @if (!isSubmitting) + { + @("Send") + } + else + { + + } + + + + + + +@code +{ + [Parameter] public string Height { get; set; } = "80px"; + [Parameter] public string Width { get; set; } = "70%"; + [Parameter] public ChatSessionsModel Sessions { get; set; } = new ChatSessionsModel(); + [Parameter] public EventCallback OnSessionCreated { get; set; } + [Parameter] public EventCallback OnMessageSubmitted { get; set; } + + public class ChatMessageInputModel + { + [Required(ErrorMessage = "Message is required.")] + public string NewMessage { get; set; } = string.Empty; + } + + private ChatMessageInputModel messageModel { get; set; } = new(); + private EditContext? editContext; + private bool isSubmitting = false; + + protected override void OnInitialized() + { + editContext = new EditContext(messageModel); + } + + private async Task SubmitMessage() + { + if (editContext is not null && editContext.Validate()) + { + isSubmitting = true; + StateHasChanged(); + try + { + var newMessageModel = new ChatMessageModel + { + Id = Guid.NewGuid(), + Role = "User", + Content = messageModel.NewMessage, + Timestamp = DateTimeOffset.Now + }; + if (Sessions.ActiveSession == null) + { + var chatSession = await chatService.CreateSessionAsync(messageModel.NewMessage); + await OnSessionCreated.InvokeAsync(chatSession); + } + else + { + newMessageModel.ChatSessionId = Sessions.ActiveSession.Id; + await chatService.SendMessageAsync(Sessions.ActiveSession!.Id, messageModel.NewMessage); + await OnMessageSubmitted.InvokeAsync(); + } + ClearForm(); + } + finally + { + isSubmitting = false; + StateHasChanged(); + } + } + } + + private void ClearForm() + { + messageModel.NewMessage = string.Empty; + editContext = new EditContext(messageModel); + } +} diff --git a/src/Presentation.Blazor/Pages/Chat/Components/NewChatSessionButton.razor b/src/Presentation.Blazor/Pages/Chat/Components/NewChatSessionButton.razor new file mode 100644 index 0000000..a48be81 --- /dev/null +++ b/src/Presentation.Blazor/Pages/Chat/Components/NewChatSessionButton.razor @@ -0,0 +1,17 @@ + + + @Title + + +@code +{ + [Parameter] public EventCallback OnNewSessionPressed { get; set; } + [Parameter] public string? Title { get; set; } = "New Chat"; + [Parameter] public string? Style { get; set; } + [Parameter] public string? Class { get; set; } + + private async Task NewSessionAsync() + { + await OnNewSessionPressed.InvokeAsync(); + } +} diff --git a/src/Presentation.Blazor/Pages/Chat/Models/ChatMessageModel.cs b/src/Presentation.Blazor/Pages/Chat/Models/ChatMessageModel.cs new file mode 100644 index 0000000..c1b81c5 --- /dev/null +++ b/src/Presentation.Blazor/Pages/Chat/Models/ChatMessageModel.cs @@ -0,0 +1,25 @@ +using Goodtocode.AgentFramework.Presentation.WebApi.Client; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Models; + +public class ChatMessageModel +{ + + public static ChatMessageModel Create(ChatMessageDto chatMessage) + { + return new ChatMessageModel + { + Id = chatMessage.Id, + ChatSessionId = chatMessage.ChatSessionId, + Role = chatMessage.Role, + Content = chatMessage.Content, + Timestamp = chatMessage.Timestamp + }; + } + + public Guid Id { get; set; } = Guid.Empty; + public Guid ChatSessionId { get; set; } = Guid.Empty; + public string Role { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public DateTimeOffset Timestamp { get; set; } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Pages/Chat/Models/ChatSessionModel.cs b/src/Presentation.Blazor/Pages/Chat/Models/ChatSessionModel.cs new file mode 100644 index 0000000..71b6766 --- /dev/null +++ b/src/Presentation.Blazor/Pages/Chat/Models/ChatSessionModel.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using Goodtocode.AgentFramework.Presentation.WebApi.Client; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Models; + +public class ChatSessionModel : INotifyPropertyChanged +{ + public static List Create(ICollection chatSessions) + { + return [.. chatSessions.Select(dto => new ChatSessionModel + { + Id = dto.Id, + Title = dto.Title, + ActorId = dto.ActorId, + Timestamp = dto.Timestamp, + IsSelected = false, + Messages = [.. dto.Messages.Select(m => new ChatMessageModel + { + Id = m.Id, + Content = m.Content, + Role = m.Role, + Timestamp = m.Timestamp + })] + })]; + } + + public static ChatSessionModel Create(ChatSessionDto chatSession) + { + return new ChatSessionModel + { + Id = chatSession.Id, + Title = chatSession.Title, + ActorId = chatSession.ActorId, + Timestamp = chatSession.Timestamp, + IsSelected = false, + Messages = [.. chatSession.Messages.Select(m => new ChatMessageModel + { + Id = m.Id, + Content = m.Content, + Role = m.Role, + Timestamp = m.Timestamp + })] + }; + } + + public Guid Id { get; set; } = Guid.Empty; + public string Title { get; set; } = string.Empty; + public Guid ActorId { get; set; } = Guid.Empty; + public DateTimeOffset Timestamp { get; set; } + public virtual ICollection? Messages { get; set; } + + private bool _isSelected; + public bool IsSelected + { + get => _isSelected; + set + { + if (_isSelected != value) + { + _isSelected = value; + OnPropertyChanged(nameof(IsSelected)); + } + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/src/Presentation.Blazor/Pages/Chat/Models/ChatSessionTreeViewItem.cs b/src/Presentation.Blazor/Pages/Chat/Models/ChatSessionTreeViewItem.cs new file mode 100644 index 0000000..a3523a5 --- /dev/null +++ b/src/Presentation.Blazor/Pages/Chat/Models/ChatSessionTreeViewItem.cs @@ -0,0 +1,47 @@ +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Models +{ + public class ChatSessionTreeViewItem : ITreeViewItem + { + public ChatSessionModel Session { get; } + + public ChatSessionTreeViewItem(ChatSessionModel session) + { + Session = session; + } + + public static ChatSessionTreeViewItem CreateFrom(ChatSessionModel session) + => new(session); + + // ITreeViewItem implementation + public string Id + { + get { return Session.Id.ToString(); } + set { } + } + + public string Text + { + get { return string.IsNullOrWhiteSpace(Session.Title) ? "Untitled Session" : Session.Title; } + set { } + } + + // Flat list, no children + public IEnumerable? Children => null; + + public IEnumerable? Items + { + get => Children; + set { } + } + + public Icon? IconCollapsed { get; set; } + public Icon? IconExpanded { get; set; } + public bool Disabled { get; set; } + public bool Expanded { get; set; } + public Func? OnExpandedAsync { get; set; } + + // Add other ITreeViewItem members as required by your Fluent UI version + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Pages/Chat/Models/ChatSessionsModel.cs b/src/Presentation.Blazor/Pages/Chat/Models/ChatSessionsModel.cs new file mode 100644 index 0000000..20735da --- /dev/null +++ b/src/Presentation.Blazor/Pages/Chat/Models/ChatSessionsModel.cs @@ -0,0 +1,137 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Models; + +public class ChatSessionsModel : Collection +{ + private bool _isHandlingPropertyChanged; + + public event EventHandler? SessionIsActiveChanged; + public ChatSessionModel? ActiveSession => this.FirstOrDefault(x => x.IsSelected); + + + public void RefreshItem(ChatSessionModel item) + { + if (item.Id == Guid.Empty) + { + return; + } + var existingItem = this.FirstOrDefault(x => x.Id == item.Id); + var existingIndex = IndexOf(existingItem ?? new ChatSessionModel()); + if (existingIndex >= 0) + { + SetItem(existingIndex, item); + if (existingItem!.IsSelected) + { + SetActive(existingIndex); + } + } + else + { + Add(item); + } + } + + protected override void InsertItem(int index, ChatSessionModel item) + { + base.InsertItem(index, item); + Subscribe(item); + } + + protected override void RemoveItem(int index) + { + var item = this[index]; + Unsubscribe(item); + base.RemoveItem(index); + } + + protected override void ClearItems() + { + foreach (var item in this) + { + Unsubscribe(item); + } + base.ClearItems(); + } + + protected override void SetItem(int index, ChatSessionModel item) + { + Unsubscribe(this[index]); + base.SetItem(index, item); + Subscribe(item); + } + + public void AddRange(IEnumerable items) + { + foreach (var item in items) + { + Add(item); + } + } + + private void Subscribe(ChatSessionModel item) + { + item.PropertyChanged += OnItemPropertyChanged; + } + + private void Unsubscribe(ChatSessionModel item) + { + item.PropertyChanged -= OnItemPropertyChanged; + } + + private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_isHandlingPropertyChanged) return; + + if (e.PropertyName == nameof(ChatSessionModel.IsSelected) && sender is ChatSessionModel session) + { + _isHandlingPropertyChanged = true; + try + { + if (session.IsSelected) + { + foreach (var s in this) + { + if (!ReferenceEquals(s, session) && s.IsSelected) + { + s.IsSelected = false; + } + } + } + SessionIsActiveChanged?.Invoke(this, session); + } + finally + { + _isHandlingPropertyChanged = false; + } + } + } + + public void SetActive(ChatSessionModel session) + { + if (!ReferenceEquals(session, ActiveSession)) + { + Unsubscribe(session); + session.IsSelected = true; + Subscribe(session); + } + } + + public void SetActive(int index) + { + if (index >= 0 && index < Count) + { + SetActive(this[index]); + } + } + + public void ClearActive() + { + if (ActiveSession != null) + { + Unsubscribe(ActiveSession); + ActiveSession.IsSelected = false; + } + } +} diff --git a/src/Presentation.Blazor/Pages/Chat/Services/ChatService.cs b/src/Presentation.Blazor/Pages/Chat/Services/ChatService.cs new file mode 100644 index 0000000..487eb06 --- /dev/null +++ b/src/Presentation.Blazor/Pages/Chat/Services/ChatService.cs @@ -0,0 +1,70 @@ +using Goodtocode.AgentFramework.Presentation.WebApi.Client; +using Goodtocode.AgentFramework.Presentation.Blazor.Services; +using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth; +using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Models; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Services; + +public interface IChatService +{ + Task> GetChatSessionsAsync(); + Task GetChatSessionAsync(Guid chatSessionId); + Task CreateSessionAsync(string firstMessage); + Task RenameSessionAsync(Guid chatSessionId, string newTitle); + Task SendMessageAsync(Guid chatSessionId, string newMessage); +} + +public class ChatService(BackendApiClient client, IUserClaimsInfo userInfo) : ApiService, IChatService +{ + private readonly BackendApiClient _apiClient = client; + private readonly IUserClaimsInfo _userInfo = userInfo; + + public async Task> GetChatSessionsAsync() + { + var response = await HandleApiException(() => _apiClient.GetMyChatSessionsPaginatedAsync( + DateTime.UtcNow.AddDays(-30), + DateTime.UtcNow, + 1, + 20 + )); + + return ChatSessionModel.Create(response.Items); + } + + public async Task GetChatSessionAsync(Guid chatSessionId) + { + var response = await HandleApiException(() => _apiClient.GetMyChatSessionAsync( + chatSessionId)); + + return ChatSessionModel.Create(response); + } + + public async Task CreateSessionAsync(string firstMessage) + { + var command = new CreateChatSessionCommand + { + ActorId = _userInfo.ObjectId, + Message = firstMessage + }; + var response = await HandleApiException(() => _apiClient.CreateChatSessionCommandAsync(command)); + + return ChatSessionModel.Create(response); + } + + public async Task RenameSessionAsync(Guid chatSessionId, string newTitle) + { + await HandleApiException(() => _apiClient.PatchChatSessionCommandAsync(chatSessionId, new PatchChatSessionCommand { Id = chatSessionId, Title = newTitle })); + } + + public async Task SendMessageAsync(Guid chatSessionId, string newMessage) + { + var response = await HandleApiException(() => _apiClient.CreateChatMessageCommandAsync( + new CreateChatMessageCommand + { + ChatSessionId = chatSessionId, + Message = newMessage + })); + + return ChatMessageModel.Create(response); + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Pages/ChatPage.razor b/src/Presentation.Blazor/Pages/ChatPage.razor new file mode 100644 index 0000000..4942c14 --- /dev/null +++ b/src/Presentation.Blazor/Pages/ChatPage.razor @@ -0,0 +1,120 @@ +@page "/chat" + +@using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Components +@using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Models +@using Goodtocode.AgentFramework.Presentation.Blazor.Pages.Chat.Services +@using Microsoft.AspNetCore.Authorization + +@attribute [Authorize] + +@inject IChatService chatService + +Agent Framework Chat Session + + + + + + + @if (sessionListOrientation == Orientation.Vertical) + { + + } + else + { + + } + + + + + What can I help you with? + + + @if (sessionListOrientation == Orientation.Vertical) + { + + } + else + { + + + } + + + + +@code { + private ChatSessionsModel chatSessions = new ChatSessionsModel(); + private ChatSessionList? chatSessionListRef; + private ChatSessionScroll? chatSessionScrollRef; + private Orientation sessionListOrientation = Orientation.Vertical; + private int sessionListVerticalGap; + private int sessionListHorizontalGap; + private bool wrapSessions = false; + private string chatContentStyle = "max-width:50vw;"; + + protected override async Task OnInitializedAsync() + { + chatSessions = new ChatSessionsModel(); + chatSessions.AddRange(await chatService.GetChatSessionsAsync()); + StateHasChanged(); + } + + private void HandleNewSessionPressed() + { + chatSessionListRef?.ClearSelection(); + chatSessionScrollRef?.ClearSelection(); + chatSessions.ClearActive(); + StateHasChanged(); + } + + private void HandleSessionCreated(ChatSessionModel chatSession) + { + chatSessions.Add(chatSession); + chatSessions.SetActive(chatSession); + StateHasChanged(); + } + + private void HandleSessionSelected(ChatSessionModel chatSession) + { + chatSessions.ClearActive(); + chatSessions.SetActive(chatSession); + StateHasChanged(); + } + + private async Task HandleMessageSubmitted() + { + chatSessions.RefreshItem(await chatService.GetChatSessionAsync(chatSessions?.ActiveSession?.Id ?? Guid.Empty)); + StateHasChanged(); + } + + private void OnBreakpointEnterHandler(GridItemSize size) + { + wrapSessions = size == GridItemSize.Xs ? true : false; + if (size == GridItemSize.Xs || size == GridItemSize.Sm) + { + sessionListOrientation = Orientation.Horizontal; + sessionListVerticalGap = 0; + sessionListHorizontalGap = 8; + chatContentStyle = ""; + } + else + { + sessionListOrientation = Orientation.Vertical; + sessionListVerticalGap = 8; + sessionListHorizontalGap = 0; + chatContentStyle = "max-width:50vw;"; + } + StateHasChanged(); + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Pages/HomePage.razor b/src/Presentation.Blazor/Pages/HomePage.razor new file mode 100644 index 0000000..0f7c40d --- /dev/null +++ b/src/Presentation.Blazor/Pages/HomePage.razor @@ -0,0 +1,60 @@ +@page "/" +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Typography +@using Microsoft.AspNetCore.Authorization + +@attribute [AllowAnonymous] + +Home + + + Welcome to Agent Framework + + + Simplifying the cloud for IT professionals. Gain actionable intelligence, manage your digital assets, and empower your team with a modern, approachable dashboard. + + + + + + + + Asset Management + + + + Easily add, organize, and monitor your digital assets in one place. + + + + + + + Agent Enrollments + + + + Deploy and manage digital agents to automate and streamline your workflows. + + + + + + + Insights & Analytics + + + + Visualize trends, track performance, and make data-driven decisions with confidence. + + + + + +@code { + [Inject] private NavigationManager Navigation { get; set; } = default!; + + private void NavigateToAssets() + { + Navigation.NavigateTo("/assets"); + } +} diff --git a/src/Presentation.Blazor/Pages/_Imports.razor b/src/Presentation.Blazor/Pages/_Imports.razor new file mode 100644 index 0000000..4a26eb1 --- /dev/null +++ b/src/Presentation.Blazor/Pages/_Imports.razor @@ -0,0 +1,17 @@ +@using System.ComponentModel.DataAnnotations +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.FluentUI.AspNetCore.Components +@using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons +@using Microsoft.JSInterop +@using Goodtocode.AgentFramework.Presentation.Blazor.Components +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Skeleton +@using Goodtocode.AgentFramework.Presentation.Blazor.Components.Typography +@using Goodtocode.AgentFramework.Presentation.WebApi.Client \ No newline at end of file diff --git a/src/Presentation.Blazor/Presentation.Blazor.csproj b/src/Presentation.Blazor/Presentation.Blazor.csproj new file mode 100644 index 0000000..d5c90ed --- /dev/null +++ b/src/Presentation.Blazor/Presentation.Blazor.csproj @@ -0,0 +1,29 @@ + + + + Goodtocode.AgentFramework.Presentation.Blazor + Goodtocode.AgentFramework.Presentation.Blazor + 1.0.0 + net10.0 + enable + enable + c2325466-9b0d-494a-984a-11ad18457d3f + + + + + + + + + + + + + + + + + + + diff --git a/src/Presentation.Blazor/Program.cs b/src/Presentation.Blazor/Program.cs new file mode 100644 index 0000000..70d1f02 --- /dev/null +++ b/src/Presentation.Blazor/Program.cs @@ -0,0 +1,66 @@ +using Azure.Monitor.OpenTelemetry.AspNetCore; +using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Routing; +using Goodtocode.AgentFramework.Presentation.Blazor; +using Goodtocode.AgentFramework.Presentation.Blazor.Components; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.FluentUI.AspNetCore.Components; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddLocalEnvironment(); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddFluentUIComponents(); + +builder.Services.AddUserClaimsSyncService(); + +builder.Services.AddAuthenticationForDownstream(builder.Configuration); + +builder.Services.AddAuthorization(); + +builder.Services.AddOpenTelemetry().UseAzureMonitor(options => +{ + options.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"]; +}); + +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddBackendApi(builder.Configuration); + +builder.Services.AddFrontendServices(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment() || app.Environment.IsLocal()) +{ + app.UseDeveloperExceptionPage(); +} +else +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + // ToDo: Add CSP Header +} + +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto +}); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.UseHttpsRedirection(); + +app.UseAntiforgery(); + +app.MapStaticAssets(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.MapGroup("/authentication").MapSignInSignOut(); + +app.Run(); diff --git a/src/Presentation.Blazor/Properties/launchSettings.json b/src/Presentation.Blazor/Properties/launchSettings.json new file mode 100644 index 0000000..39aaa11 --- /dev/null +++ b/src/Presentation.Blazor/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:7165", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Local" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7175;http://localhost:7165", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Local" + } + } + } +} diff --git a/src/Presentation.Blazor/Properties/serviceDependencies.json b/src/Presentation.Blazor/Properties/serviceDependencies.json new file mode 100644 index 0000000..4962cc5 --- /dev/null +++ b/src/Presentation.Blazor/Properties/serviceDependencies.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights", + "dynamicId": null + } + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Properties/serviceDependencies.local.json b/src/Presentation.Blazor/Properties/serviceDependencies.local.json new file mode 100644 index 0000000..8ee2741 --- /dev/null +++ b/src/Presentation.Blazor/Properties/serviceDependencies.local.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights.sdk", + "dynamicId": null + } + } +} \ No newline at end of file diff --git a/src/Presentation.Blazor/Services/ApiService.cs b/src/Presentation.Blazor/Services/ApiService.cs new file mode 100644 index 0000000..4eece13 --- /dev/null +++ b/src/Presentation.Blazor/Services/ApiService.cs @@ -0,0 +1,55 @@ +using Goodtocode.AgentFramework.Presentation.WebApi.Client; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Services; + +public abstract class ApiService +{ + protected static async Task HandleApiException(Func apiCall) + { + try + { + await apiCall().ConfigureAwait(false); + } + catch (ApiException ex) when (ex.StatusCode == 400) + { + var errors = ParseValidationErrors(ex.Response); + throw new ValidationException("Validation failed", null, errors); + } + } + + protected static async Task HandleApiException(Func> apiCall) + { + try + { + return await apiCall().ConfigureAwait(false); + } + catch (ApiException ex) when (ex.StatusCode == 400) + { + var errors = ParseValidationErrors(ex.Response); + throw new ValidationException("Validation failed", null, errors); + } + } + + protected static Dictionary> ParseValidationErrors(string content) + { + var result = new Dictionary>(); + if (!string.IsNullOrEmpty(content)) + { + var doc = JsonDocument.Parse(content); + if (doc.RootElement.TryGetProperty("errors", out var errorsElement)) + { + foreach (var property in errorsElement.EnumerateObject()) + { + result[property.Name] = property.Value + .EnumerateArray() + .Select(e => e.GetString()) + .Where(s => s != null) + .ToList()!; + } + } + } + return result; + } +} diff --git a/src/Presentation.Blazor/Services/LocalStorageService.cs b/src/Presentation.Blazor/Services/LocalStorageService.cs new file mode 100644 index 0000000..9c278a3 --- /dev/null +++ b/src/Presentation.Blazor/Services/LocalStorageService.cs @@ -0,0 +1,32 @@ +using Microsoft.JSInterop; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Services; + +public static class LocalStorageFunctions +{ + public const string GetItem = "localStorage.getItem"; + public const string SetItem = "localStorage.setItem"; + public const string RemoveItem = "localStorage.removeItem"; + public const string Clear = "localStorage.clear"; +} + +public interface ILocalStorageService +{ + Task SetItemAsync(string key, string value); + Task GetItemAsync(string key); +} + +public class LocalStorageService(IJSRuntime jsRuntime) : ILocalStorageService +{ + private readonly IJSRuntime _jsRuntime = jsRuntime; + + public async Task SetItemAsync(string key, string value) + { + await _jsRuntime.InvokeVoidAsync(LocalStorageFunctions.SetItem, key, value); + } + + public async Task GetItemAsync(string key) + { + return await _jsRuntime.InvokeAsync(LocalStorageFunctions.GetItem, key); + } +} diff --git a/src/Presentation.Blazor/Services/UserSyncService.cs b/src/Presentation.Blazor/Services/UserSyncService.cs new file mode 100644 index 0000000..9f595bc --- /dev/null +++ b/src/Presentation.Blazor/Services/UserSyncService.cs @@ -0,0 +1,73 @@ +using Goodtocode.AgentFramework.Presentation.WebApi.Client; +using System.Security.Authentication; +using System.Security.Claims; +using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth.Services; +using Goodtocode.AgentFramework.Presentation.Blazor.Components.Auth; + +namespace Goodtocode.AgentFramework.Presentation.Blazor.Services; + +public class UserSyncService(BackendApiClient apiClient, IUserClaimsInfo userInfo) : ApiService, IUserSyncService +{ + private readonly BackendApiClient _apiClient = apiClient; + private readonly IUserClaimsInfo _userInfo = userInfo; + + public const string UserSyncClaimName = "user_sync_status"; + + public enum SyncStatus + { + Pending, + Synced, + Failed + } + + private bool _isSyncing; + + public void UserChanged(ClaimsPrincipal? user) + { + SetSyncStatus(user, SyncStatus.Pending); + } + + public async Task SyncUserAsync(ClaimsPrincipal? user) + { + if (_isSyncing) return; + _isSyncing = true; + try + { + ClaimsIdentity? identity = user?.Identity as ClaimsIdentity ?? throw new AuthenticationException("User identity is missing or invalid."); + + var syncClaim = identity.FindFirst(UserSyncClaimName)?.Value ?? UserSyncService.SyncStatus.Pending.ToString(); + if (identity.IsAuthenticated && syncClaim == UserSyncService.SyncStatus.Pending.ToString()) + { + await HandleApiException(() => _apiClient.SaveMyActorAsync(new SaveMyActorCommand + { + TenantId = _userInfo.TenantId, + FirstName = _userInfo.Givenname, + LastName = _userInfo.Surname, + Email = _userInfo.Email + })); + } + SetSyncStatus(user, SyncStatus.Synced); + } + catch (Exception) + { + SetSyncStatus(user, SyncStatus.Failed); + throw; + } + finally + { + _isSyncing = false; + } + } + + protected void SetSyncStatus(ClaimsPrincipal? user, SyncStatus newStatus) + { + ClaimsIdentity? identity = user?.Identity as ClaimsIdentity ?? throw new AuthenticationException("User identity is missing or invalid."); + var existingClaim = identity.FindFirst(UserSyncClaimName); + if (existingClaim != null) + { + identity.RemoveClaim(existingClaim); + } + + identity.AddClaim(new Claim(UserSyncClaimName, newStatus.ToString())); + } +} diff --git a/src/Presentation.Blazor/appsettings.Development.json b/src/Presentation.Blazor/appsettings.Development.json new file mode 100644 index 0000000..9dabaa1 --- /dev/null +++ b/src/Presentation.Blazor/appsettings.Development.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ApplicationInsights": { + "ConnectionString": "" + }, + "Clarity": { + "ProjectId": "" + }, + "AllowedHosts": "*", + "BackendApi": { + "BaseUrl": "", + "ClientId": "" + }, + "EntraExternalId": { + "Instance": "", + "TenantId": "", + "ClientId": "", + "ValidateAuthority": true + } +} diff --git a/src/Presentation.Blazor/appsettings.Local.json b/src/Presentation.Blazor/appsettings.Local.json new file mode 100644 index 0000000..2d69954 --- /dev/null +++ b/src/Presentation.Blazor/appsettings.Local.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ApplicationInsights": { + "ConnectionString": "InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=applicationinsights.azure.com" + }, + "Clarity": { + "ProjectId": "" + }, + "AllowedHosts": "*", + "BackendApi": { + "BaseUrl": "https://localhost:6075", + "ClientId": "" + }, + "EntraExternalId": { + "Instance": "", + "TenantId": "", + "ClientId": "", + "ValidateAuthority": true + } +} diff --git a/src/Presentation.Blazor/appsettings.Production.json b/src/Presentation.Blazor/appsettings.Production.json new file mode 100644 index 0000000..9dabaa1 --- /dev/null +++ b/src/Presentation.Blazor/appsettings.Production.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ApplicationInsights": { + "ConnectionString": "" + }, + "Clarity": { + "ProjectId": "" + }, + "AllowedHosts": "*", + "BackendApi": { + "BaseUrl": "", + "ClientId": "" + }, + "EntraExternalId": { + "Instance": "", + "TenantId": "", + "ClientId": "", + "ValidateAuthority": true + } +} diff --git a/src/Presentation.Blazor/appsettings.json b/src/Presentation.Blazor/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Presentation.Blazor/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Presentation.Blazor/wwwroot/app.css b/src/Presentation.Blazor/wwwroot/app.css new file mode 100644 index 0000000..85e47f5 --- /dev/null +++ b/src/Presentation.Blazor/wwwroot/app.css @@ -0,0 +1,14 @@ + +:root { + /* Agent Framework (DI-UX) Branding Variables */ + --di-font: 'Segoe UI', 'Inter', Arial, sans-serif; + /* Fluent UI variable overrides for light mode */ + --fluent-header-background: #181f2a; /* dark header */ + --fluent-header-foreground: #f3f4f6; /* light text */ + --fluent-body-background: #F9FAFB; /* light body/nav */ + --fluent-body-foreground: #111827; /* dark text */ +} + +html, body { + font-family: var(--di-font); +} diff --git a/src/Presentation.Blazor/wwwroot/css/site.css b/src/Presentation.Blazor/wwwroot/css/site.css new file mode 100644 index 0000000..e69de29 diff --git a/src/Presentation.Blazor/wwwroot/favicon.png b/src/Presentation.Blazor/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..9b3a8ab318963462d44972e02f614279e95ffa9b GIT binary patch literal 1380 zcmV-q1)KVbP)00001b5ch_0Itp) z=>Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D02y>eSaefwW^{L9 za%BK;VQFr3E^cLXAT%y8E-^PVZl7ZS00h)YL_t(&L+zMNY};fU#<}c<#Dz0=ZXCI_ zYE{|;h*k)RNhz=?1F3xN&}|e=Fa&}L!q(AE69)>~q{4s$(uy{=s$-=|9JiDdrf%y> z+cmA5?>I@5*p5GVjI*@3iCw?86;1M^{~_`lKmX@vzw!Gzk0=khJb+fq18B88fL6-` zXtj(0WJO{A+GnX`oMR}Av&=qAW$f7$0H=wPtjlLwf+!pG(>!(gOv~7_Ie?<@qGf}hZZd5G zjTh_3SJLJ699J;jjW+Ux;jvH7z;N)RQ@5Go(gVXidKv*#B^g^;^h*@Bi($EY@xp6| zhdQ5q;ls~I;`!~N%$9%)+`Ly~&vT<=u=2ls^wwH-EnC@Xh%)tT3IIv6jDEOgu&5Va zFJQW`n%MvH!S>E)MlXz&`PvX(R&c4B_fn79e6 zQpwlf-`3VP_T$f+d3EOCiu`xEEsGEU!qfyqx+siZ{0>okzWz)$R~u85L^3roML#td ze)QGQlZQk29Ah@MfK^c(96W+k0>dMCB;ZcsCqWcm6*&M}in3DTrDTym85l;Ofj8c) za6E=qV;fg)(8!JST0Jok`el+{T3N#s4+OrzmyaA9TF;bBA2OyQ2Y}F5l$0%A+{~4a z4jo6V4^Es|SX>>Sq>;FuzRqyzDt-Ct1b+7i{fAZ(Io!Q6FB&}K_aa*W5Sruu7dY{L zl0EeL!0D0m7ssi`{rdD;;rLfS{r=5`v4v#@_f7r7hj8X^%Ps(f2!g0SSf?Vq2SUS_sPG@R*e$+F^w`nK0RTv-ilLeL!1od4(F>r;*vyql*JJWKfPPp<@oKWTo+e<>(%*@t zHgYZ6%%L0BQLLuP0)NjU!*+U>%ayB`Kg7E^asY^o!NK&9Eda~F@sC#A;$oqW(@A7hnxg}b~E6m-o5mY!vN4u2CQ*GYUWNX=~8;g zc>t)+kSYpmnG(bDE~STD0f00H>~OG6C{)}^54i;ZX$*oWtM9(9gjeMvfF>k&FFoWs mfZOr_S}hNt)q2PQ6y;yI0OT7A1s1RX0000h^a literal 0 HcmV?d00001 diff --git a/src/Presentation.Blazor/wwwroot/img/goodtocode-logo.png b/src/Presentation.Blazor/wwwroot/img/goodtocode-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e1a2d042ab60aa02145b7ea5b077754c6941e8d1 GIT binary patch literal 4793 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 literal 0 HcmV?d00001 diff --git a/src/Presentation.WebApi/Actor/MyActorController.cs b/src/Presentation.WebApi/Actor/MyActorController.cs new file mode 100644 index 0000000..e55dd17 --- /dev/null +++ b/src/Presentation.WebApi/Actor/MyActorController.cs @@ -0,0 +1,73 @@ +using Goodtocode.AgentFramework.Core.Application.Actor; +using Goodtocode.AgentFramework.Presentation.WebApi.Common; +using Microsoft.AspNetCore.Authorization; + +namespace Goodtocode.AgentFramework.Presentation.WebApi.Actor; + +/// +/// Actor endpoints to create a chat, continue a chat, delete a chat and retrieve chat history +/// +[ApiController] +[ApiConventionType(typeof(DefaultApiConventions))] +[Route("api/v{version:apiVersion}my/actors")] +[ApiVersion("1.0")] +[Authorize] +public class MyActorController : ApiControllerBase +{ + /// + /// Retrieves the actor profile by external ID, including session history. + /// + /// + /// Sample request: + /// + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + /// "api-version": 1.0 + /// + /// + /// + /// ActorDto + /// { + /// Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// Name: John Doe + /// } + /// + [HttpGet(Name = "GetMyActorProfile")] + [ProducesResponseType(typeof(ActorDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GetMyActorProfile() + { + return await Mediator.Send(new GetMyActorQuery()); + } + + /// + /// Creates a new actor session with empty history. + /// + /// + /// Sample request: + /// + /// HttpPost Body + /// { + /// "Id": 00000000-0000-0000-0000-000000000000, + /// "Name": "John Doe" + /// } + /// + /// "version": 1.0 + /// + /// The command containing actor creation details. + /// + /// The created ActorDto object. + /// + [HttpPost(Name = "SaveMyActor")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task SaveMyActor(SaveMyActorCommand command) + { + var response = await Mediator.Send(command); + return CreatedAtAction(nameof(GetMyActorProfile), new { response.OwnerId }, response); + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/Audio/AdminAudioController.cs b/src/Presentation.WebApi/Audio/AdminAudioController.cs new file mode 100644 index 0000000..0df4020 --- /dev/null +++ b/src/Presentation.WebApi/Audio/AdminAudioController.cs @@ -0,0 +1,170 @@ +using Goodtocode.AgentFramework.Core.Application.Audio; +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Presentation.WebApi.Common; +using Microsoft.AspNetCore.Authorization; + +namespace Goodtocode.AgentFramework.Presentation.WebApi.Audio; + +/// +/// Text Audio endpoints to create a chat, continue a chat, delete a chat and retrieve chat history +/// +[ApiController] +[ApiConventionType(typeof(DefaultApiConventions))] +[Route("api/v{version:apiVersion}/admin/audio")] +[Authorize] +[ApiVersion("1.0")] +public class AdminAudioController : ApiControllerBase +{ + /// Get Text Audio with history + /// + /// Sample request: + /// + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + /// "api-version": 1.0 + /// + /// + /// + /// TextAudioDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// AuthorKey: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet("{id}", Name = "GetTextAudioQuery")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Get(Guid id) + { + return await Mediator.Send(new GetTextAudioQuery + { + Id = id + }); + } + + /// Get All Text Audios Query + /// + /// Sample request: + /// + /// "StartDate": "2024-06-01T00:00:00Z" + /// "EndDate": "2024-12-01T00:00:00Z" + /// "api-version": 1.0 + /// + /// + /// + /// TextAudioDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Timestamp: "2024-06-03T11:21:00Z" + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet(Name = "GetTextAudiosQuery")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetAll() + { + return await Mediator.Send(new GetTextAudiosQuery()); + } + + /// Get Text Audios Paginated Query + /// + /// Sample request: + /// + /// "StartDate": "2024-06-01T00:00:00Z" + /// "EndDate": "2024-12-01T00:00:00Z" + /// "PageNumber": 1 + /// "PageSize" : 10 + /// "api-version": 1.0 + /// + /// + /// + /// TextAudioDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet("Paginated", Name = "GetTextAudioPaginatedQuery")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetTextAudioPaginatedQuery([FromQuery] GetTextAudioPaginatedQuery query) + { + return await Mediator.Send(query); + } + + /// + /// Creates new Audio from Text + /// + /// + /// Sample request: + /// + /// HttpPost Body + /// { + /// "Id": 00000000-0000-0000-0000-000000000000, + /// "Message": "Hi, I am interested in learning about Agent Framework." + /// } + /// + /// "version": 1.0 + /// + /// + /// + /// TextAudioDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpPost(Name = "CreateTextToAudioCommand")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Post(CreateTextToAudioCommand command) + { + var response = await Mediator.Send(command); + return CreatedAtAction(nameof(Get), new { response.Id }, response); + } + + /// Remove Audio Command + /// + /// Sample request: + /// + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + /// "api-version": 1.0 + /// + /// + /// NoContent + [HttpDelete("{id}", Name = "RemoveTextAudioCommand")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [ProducesDefaultResponseType] + public async Task Delete(Guid id) + { + await Mediator.Send(new DeleteTextAudioCommand() { Id = id }); + + return NoContent(); + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/Auth/ClaimsUserInfo.cs b/src/Presentation.WebApi/Auth/ClaimsUserInfo.cs new file mode 100644 index 0000000..d48f71b --- /dev/null +++ b/src/Presentation.WebApi/Auth/ClaimsUserInfo.cs @@ -0,0 +1,62 @@ +namespace Goodtocode.AgentFramework.Presentation.WebApi.Auth; + +/// +/// User information implementation that retrieves data from the current HTTP context. +/// +/// HttpContext containing claims +public class ClaimsUserInfo(IHttpContextAccessor contextAccessor) : IClaimsUserInfo +{ + private readonly HttpContext? context = contextAccessor?.HttpContext; + + /// + /// Gets the unique identifier of the user object associated with the current context. + /// + /// This property retrieves the value of the "objectidentifier" claim from the current user's context. + /// Ensure that the claim is present and properly formatted as a GUID in the authentication token. + public Guid ObjectId => Guid.TryParse( + context?.User.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value, + out var objectId) ? objectId : Guid.Empty; + + /// + /// Gets the unique identifier of the tenant associated with the current user. + /// + /// The tenant ID is extracted from the user's claims using the claim type + /// "http://schemas.microsoft.com/identity/claims/tenantid". Ensure that the claim is present and valid in the + /// user's identity for this property to return a meaningful value. + public Guid TenantId => Guid.TryParse( + context?.User.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value, + out var tenantId) ? tenantId : Guid.Empty; + + /// + /// Gets the first name of the user based on the associated claims. + /// + public string Givenname => context?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname")?.Value ?? string.Empty; + + /// + /// Gets the last name of the current user based on their claims. + /// + public string Surname => context?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname")?.Value ?? string.Empty; + + /// + /// Gets the email address of the current user based on their User Principal Name (UPN) claim. + /// + public string Email => context?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn")?.Value ?? string.Empty; + + /// + /// Gets the collection of scopes associated with the current user. + /// + /// Scopes are typically used to define the permissions or access levels granted to the user. + /// This property retrieves the scopes from the user's claims, specifically from the claim with the type + /// "http://schemas.microsoft.com/identity/claims/scope". + public ICollection Scopes => context?.User.FindFirst("http://schemas.microsoft.com/identity/claims/scope")?.Value?.Split(' ').ToList() ?? []; + + /// + /// Gets the collection of roles associated with the current user. + /// + public ICollection Roles => context?.User.FindAll("http://schemas.microsoft.com/ws/2008/06/identity/claims/role").Select(c => c.Value).ToList() ?? []; + + /// + /// Gets the collection of group names associated with the current user. + /// + public ICollection Groups => context?.User.FindAll("groups").Select(c => c.Value).ToList() ?? []; +} \ No newline at end of file diff --git a/src/Presentation.WebApi/Auth/ConfigureServices.cs b/src/Presentation.WebApi/Auth/ConfigureServices.cs new file mode 100644 index 0000000..4fde5a4 --- /dev/null +++ b/src/Presentation.WebApi/Auth/ConfigureServices.cs @@ -0,0 +1,44 @@ + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Identity.Web; + +namespace Goodtocode.AgentFramework.Presentation.WebApi.Auth; + +/// +/// Presentation Layer WebApi Configuration +/// +public static class ConfigureServices +{ + private struct TokenRoleClaimTypes + { + public const string Roles = "roles"; + public const string Groups = "groups"; // I.e. EID Security Groups + } + + /// + /// AddUserInfo + /// + /// + /// + /// + public static IServiceCollection AddAuthenticationWithRoles(this IServiceCollection services, IConfiguration configuration) + { + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi( + jwtOptions => + { + configuration.GetSection("EntraExternalId").Bind(jwtOptions); + //jwtOptions.TokenValidationParameters.RoleClaimType = TokenRoleClaimTypes.Roles; + }, + identityOptions => + { + configuration.GetSection("EntraExternalId").Bind(identityOptions); + }); + + services.AddScoped(); + services.AddScoped(typeof(IPipelineBehavior<>), typeof(UserInfoBehavior<>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(UserInfoBehavior<,>)); + + return services; + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/Auth/IClaimsUserInfo.cs b/src/Presentation.WebApi/Auth/IClaimsUserInfo.cs new file mode 100644 index 0000000..5851963 --- /dev/null +++ b/src/Presentation.WebApi/Auth/IClaimsUserInfo.cs @@ -0,0 +1,50 @@ +namespace Goodtocode.AgentFramework.Presentation.WebApi.Auth; + +/// +/// Represents a user's information, including identifiers, personal details, and contact information. +/// +/// This interface provides a standardized structure for accessing user-related data, such as unique +/// identifiers, name details, and email address. It is commonly used in scenarios where user identity and contact +/// information need to be retrieved or processed. +public interface IClaimsUserInfo +{ + /// + /// Gets the unique identifier for the object. + /// + Guid ObjectId { get; } + + /// + /// Gets the unique identifier of the tenant associated with the current context. + /// + Guid TenantId { get; } + + /// + /// Gets the first name of the individual. + /// + string Givenname { get; } + + /// + /// Gets the last name of the individual. + /// + string Surname { get; } + + /// + /// Gets the email address associated with the entity. + /// + string Email { get; } + + /// + /// Gets the highest role + /// + ICollection Roles { get; } + + /// + /// Gets the collection of scopes associated with the current operation. + /// + ICollection Scopes { get; } + + /// + /// Gets the collection of group names associated with the current user. + /// + ICollection Groups { get; } +} diff --git a/src/Presentation.WebApi/Auth/UserInfoBehavior.cs b/src/Presentation.WebApi/Auth/UserInfoBehavior.cs new file mode 100644 index 0000000..9a64c13 --- /dev/null +++ b/src/Presentation.WebApi/Auth/UserInfoBehavior.cs @@ -0,0 +1,62 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Domain.Auth; + +namespace Goodtocode.AgentFramework.Presentation.WebApi.Auth; + +/// +/// Represents a pipeline behavior that injects user information into a request before passing it to the next +/// delegate in the pipeline. +/// +/// This behavior ensures that the instance is assigned to the property of the request before invoking the next delegate. +/// The type of the request. Must implement . +/// +public class UserInfoBehavior(IClaimsUserInfo userInfo) : IPipelineBehavior + where TRequest : IUserInfoRequest +{ + /// + /// Processes the specified request and invokes the next delegate in the request pipeline. + /// + /// This method modifies the by setting its UserInfo + /// property before invoking the next handler. Ensure that is not null and is + /// called to continue the request pipeline. + /// The request to be processed. The request object may be modified during processing. + /// The delegate to invoke the next handler in the pipeline. This delegate must be called to continue processing + /// the request. + /// A token that can be used to propagate notification that the operation should be canceled. + public async Task Handle(TRequest request, RequestDelegateInvoker nextInvoker, CancellationToken cancellationToken) + { + request.UserInfo = UserEntity.Create(userInfo.ObjectId, userInfo.TenantId, userInfo.Givenname, userInfo.Surname, userInfo.Email, userInfo.Roles); + await nextInvoker(); + } +} + +/// +/// Represents a pipeline behavior that injects user information into a request before passing it to the next +/// delegate in the pipeline. +/// +/// This behavior ensures that the instance is assigned to the property of the request before invoking the next delegate. +/// The type of the request. Must implement . +/// The type of the response. +/// +public class UserInfoBehavior(IClaimsUserInfo userInfo) : IPipelineBehavior + where TRequest : IUserInfoRequest +{ + /// + /// Processes the specified request and invokes the next delegate in the request pipeline. + /// + /// This method modifies the by setting its UserInfo + /// property before invoking the next handler. Ensure that is not null and is + /// called to continue the request pipeline. + /// The request to be processed. The request object may be modified during processing. + /// The delegate to invoke the next handler in the pipeline. This delegate must be called to continue processing + /// the request. + /// A token that can be used to propagate notification that the operation should be canceled. + /// A task that represents the asynchronous operation. The task result contains the response of type . + public async Task Handle(TRequest request, RequestDelegateInvoker nextInvoker, CancellationToken cancellationToken) + { + request.UserInfo = UserEntity.Create(userInfo.ObjectId, userInfo.TenantId, userInfo.Givenname, userInfo.Surname, userInfo.Email, userInfo.Roles); + return await nextInvoker(); + } +} diff --git a/src/Presentation.WebApi/ChatCompletion/AdminChatMessageController.cs b/src/Presentation.WebApi/ChatCompletion/AdminChatMessageController.cs new file mode 100644 index 0000000..49030c5 --- /dev/null +++ b/src/Presentation.WebApi/ChatCompletion/AdminChatMessageController.cs @@ -0,0 +1,146 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Presentation.WebApi.Common; +using Microsoft.AspNetCore.Authorization; + +namespace Goodtocode.AgentFramework.Presentation.WebApi.ChatCompletion; + +/// +/// Chat completion endpoints to create a chat, continue a chat, delete a chat and retrieve chat history +/// +[ApiController] +[ApiConventionType(typeof(DefaultApiConventions))] +[Route("api/v{version:apiVersion}/admin/messages")] +[ApiVersion("1.0")] +[Authorize] +public class AdminChatMessageController : ApiControllerBase +{ + /// Get Chat Message + /// + /// Sample request: + /// + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + /// "api-version": 1.0 + /// + /// + /// + /// ChatMessageDto + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI." + /// } + /// + [HttpGet("{id}", Name = "GetChatMessageQuery")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Get(Guid id) + { + return await Mediator.Send(new GetChatMessageQuery + { + Id = id + }); + } + + /// Get All Chat Messages for a session Query + /// + /// Sample request: + /// + /// "StartDate": "2024-06-01T00:00:00Z" + /// "EndDate": "2024-12-01T00:00:00Z" + /// "api-version": 1.0 + /// + /// + /// + /// ChatMessageDto + /// [{ + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// }] + /// + [HttpGet(Name = "GetChatMessagesQuery")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetAll() + { + return await Mediator.Send(new GetChatMessagesQuery()); + } + + /// Get Chat Messages Paginated Query + /// + /// Sample request: + /// + /// "StartDate": "2024-06-01T00:00:00Z" + /// "EndDate": "2024-12-01T00:00:00Z" + /// "PageNumber": 1 + /// "PageSize" : 10 + /// "api-version": 1.0 + /// + /// + /// + /// ChatMessageDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet("Paginated", Name = "GetChatMessagesPaginatedQuery")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetPaginated([FromQuery] GetChatMessagesPaginatedQuery query) + { + return await Mediator.Send(query); + } + + /// + /// Creates new Chat Message with initial message prompt/response history + /// + /// + /// Types of Chat Completion are: + /// 1. Informational Prompt: A prompt requesting information + /// - Example Prompt: "What's the capital of France?" + /// - Example Response: "The capital of France is Paris." + /// 2. Multiple Choice Prompt: A prompt with instructions for multiple-choice responses. + /// - Example Prompt: “Choose an activity for the weekend: a) Hiking b) Movie night c) Cooking class d) Board games” + /// - Example Response: “I'd recommend hiking! It's a great way to enjoy nature and get some exercise.” + /// Sample request: + /// + /// HttpPost Body + /// { + /// "Id": 00000000-0000-0000-0000-000000000000, + /// "Message": "Hi, I am interested in learning about Agent Framework." + /// } + /// + /// "version": 1.0 + /// + /// + /// + /// ChatMessageDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpPost(Name = "CreateChatMessageCommand")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Post(CreateChatMessageCommand command) + { + var response = await Mediator.Send(command); + return CreatedAtAction(nameof(Get), new { response.Id }, response); + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/ChatCompletion/AdminChatSessionController.cs b/src/Presentation.WebApi/ChatCompletion/AdminChatSessionController.cs new file mode 100644 index 0000000..cf4ae55 --- /dev/null +++ b/src/Presentation.WebApi/ChatCompletion/AdminChatSessionController.cs @@ -0,0 +1,241 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Presentation.WebApi.Common; +using Microsoft.AspNetCore.Authorization; + +namespace Goodtocode.AgentFramework.Presentation.WebApi.ChatCompletion; + +/// +/// Chat completion endpoints to create a chat, continue a chat, delete a chat and retrieve chat history +/// +[ApiController] +[ApiConventionType(typeof(DefaultApiConventions))] +[Route("api/v{version:apiVersion}/admin/sessions")] +[ApiVersion("1.0")] +[Authorize] +public class AdminChatSessionController : ApiControllerBase +{ + /// Get Chat Session with history + /// + /// Sample request: + /// + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + /// "api-version": 1.0 + /// + /// + /// + /// ChatSessionDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// AuthorKey: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet("{id}", Name = "GetChatSessionQuery")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Get(Guid id) + { + return await Mediator.Send(new GetChatSessionQuery + { + Id = id + }); + } + + /// Get All Chat Sessions Query + /// + /// Sample request: + /// + /// "StartDate": "2024-06-01T00:00:00Z" + /// "EndDate": "2024-12-01T00:00:00Z" + /// "api-version": 1.0 + /// + /// + /// + /// ChatSessionDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Timestamp: "2024-06-03T11:21:00Z" + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet(Name = "GetChatSessionsQuery")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetAll() + { + return await Mediator.Send(new GetChatSessionsQuery()); + } + + /// Get Chat Sessions Paginated Query + /// + /// Sample request: + /// + /// "StartDate": "2024-06-01T00:00:00Z" + /// "EndDate": "2024-12-01T00:00:00Z" + /// "PageNumber": 1 + /// "PageSize" : 10 + /// "api-version": 1.0 + /// + /// + /// + /// ChatSessionDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet("Paginated", Name = "GetChatSessionsPaginatedQuery")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetChatSessionsPaginatedQuery([FromQuery] GetChatSessionsPaginatedQuery query) + { + return await Mediator.Send(query); + } + + /// + /// Creates new Chat Session with initial message prompt/response history + /// + /// + /// Types of Chat Completion are: + /// 1. Informational Prompt: A prompt requesting information + /// - Example Prompt: "What's the capital of France?" + /// - Example Response: "The capital of France is Paris." + /// 2. Multiple Choice Prompt: A prompt with instructions for multiple-choice responses. + /// - Example Prompt: “Choose an activity for the weekend: a) Hiking b) Movie night c) Cooking class d) Board games” + /// - Example Response: “I'd recommend hiking! It's a great way to enjoy nature and get some exercise.” + /// Sample request: + /// + /// HttpPost Body + /// { + /// "Id": 00000000-0000-0000-0000-000000000000, + /// "Message": "Hi, I am interested in learning about Agent Framework." + /// } + /// + /// "version": 1.0 + /// + /// + /// + /// ChatSessionDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpPost(Name = "CreateChatSessionCommand")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Post(CreateChatSessionCommand command) + { + var response = await Mediator.Send(command); + return CreatedAtAction(nameof(Get), new { response.Id }, response); + } + + /// + /// Update ChatSession Command, typically with changing the title or adding a new message + /// + /// + /// Sample request: + /// + /// HttpPut Body + /// { + /// "Id": "60fb5e99-3a78-43df-a512-7d8ff498499e", + /// "Message": "Hi, I am interested in learning about Agent Framework.", + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// + /// "version": 1.0 + /// + /// + /// + /// { + /// "Id": "60fb5e99-3a78-43df-a512-7d8ff498499e", + /// "Message": "Hi, I am interested in learning about Agent Framework.", + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + [HttpPut(Name = "UpdateChatSessionCommand")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Put(UpdateChatSessionCommand command) + { + await Mediator.Send(command); + + return NoContent(); + } + + /// + /// Patch Chat Session Command + /// + /// + /// Sample request: + /// + /// HttpPatch Body + /// { + /// "Id": "60fb5e99-3a78-43df-a512-7d8ff498499e", + /// "Title": "Agent Framework Chat Session" + /// } + /// + /// "version": 1.0 + /// + /// + /// + /// NoContent + [HttpPatch("{id}", Name = "PatchChatSessionCommand")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Patch(Guid id, PatchChatSessionCommand command) + { + command.Id = id; + await Mediator.Send(command); + + return NoContent(); + } + + /// Remove ChatSession Command + /// + /// Sample request: + /// + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + /// "api-version": 1.0 + /// + /// + /// NoContent + [HttpDelete("{id}", Name = "RemoveChatSessionCommand")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [ProducesDefaultResponseType] + public async Task Delete(Guid id) + { + await Mediator.Send(new DeleteChatSessionCommand() { Id = id }); + + return NoContent(); + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/ChatCompletion/MyChatSessionController.cs b/src/Presentation.WebApi/ChatCompletion/MyChatSessionController.cs new file mode 100644 index 0000000..5542862 --- /dev/null +++ b/src/Presentation.WebApi/ChatCompletion/MyChatSessionController.cs @@ -0,0 +1,101 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Presentation.WebApi.Common; +using Microsoft.AspNetCore.Authorization; + +namespace Goodtocode.AgentFramework.Presentation.WebApi.ChatCompletion; + +/// +/// Actor endpoints to create a chat, continue a chat, delete a chat and retrieve chat history +/// +[ApiController] +[ApiConventionType(typeof(DefaultApiConventions))] +[Route("api/v{version:apiVersion}/my/chat")] +[ApiVersion("1.0")] +[Authorize] +public class MyChatSessionController : ApiControllerBase +{ + /// + /// Retrieves all chat sessions for the specified actor. + /// + /// + /// Sample request: + /// + /// "ActorId": 60fb5e99-3a78-43df-a512-7d8ff498499e + /// "StartDate": "2024-06-01T00:00:00Z"s + /// "EndDate": "2024-12-01T00:00:00Z" + /// "api-version": 1.0 + /// + /// + /// A collection of ChatSessionDto objects representing the actor's chat sessions. + /// + [HttpGet("ChatSessions", Name = "GetMyChatSessions")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetMyChatSessions() + { + return await Mediator.Send(new GetMyChatSessionsQuery()); + } + + /// + /// Retrieves paginated chat sessions for the specified actor within an optional date range. + /// + /// + /// Sample request: + /// + /// "StartDate": "2024-06-01T00:00:00Z" + /// "EndDate": "2024-12-01T00:00:00Z" + /// "PageNumber": 1 + /// "PageSize" : 10 + /// "api-version": 1.0 + /// + /// + /// The start date for filtering sessions (optional). + /// The end date for filtering sessions (optional). + /// The page number for pagination (default is 1). + /// The page size for pagination (default is 10). + /// + /// A paginated list of ChatSessionDto objects. + /// + [HttpGet("Paginated", Name = "GetMyChatSessionsPaginated")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetMyChatSessionsPaginated(DateTime? startDate, DateTime? endDate, int pageNumber = 1, int pageSize = 10) + { + var query = new GetMyChatSessionsPaginatedQuery() + { + StartDate = startDate, + EndDate = endDate, + PageNumber = pageNumber, + PageSize = pageSize + }; + return await Mediator.Send(query); + } + + /// + /// Retrieves a specific chat session for the actor by session ID. + /// + /// + /// Sample request: + /// + /// "ActorId": 60fb5e99-3a78-43df-a512-7d8ff498499e + /// "ChatSessionId": 1efb5e99-3a78-43df-a512-7d8ff498499e + /// "api-version": 1.0 + /// + /// The identifier of the chat session. + /// + /// ChatSessionDto representing the chat session details. + /// + [HttpGet("{chatSessionId}", Name = "GetMyChatSession")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GetMyChatSession(Guid chatSessionId) + { + return await Mediator.Send(new GetMyChatSessionQuery() { ChatSessionId = chatSessionId }); + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/Common/ApiControllerBase.cs b/src/Presentation.WebApi/Common/ApiControllerBase.cs new file mode 100644 index 0000000..c8a6205 --- /dev/null +++ b/src/Presentation.WebApi/Common/ApiControllerBase.cs @@ -0,0 +1,29 @@ +using Goodtocode.AgentFramework.Core.Domain.Auth; +using Microsoft.AspNetCore.Authorization; + +namespace Goodtocode.AgentFramework.Presentation.WebApi.Common; + +/// +/// Sets up ISender Mediator property +/// +[ApiController] +[ApiExceptionFilter] +[Authorize] +//[Authorize(Roles = "User")] +public abstract class ApiControllerBase : ControllerBase +{ + private ISender? _mediator; + + /// + /// Mediator property exposing ISender type + /// + protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetRequiredService(); + + /// + /// Gets the user information associated with the current HTTP context. + /// + /// The instance is resolved from the dependency injection container + /// using the current HTTP context's request services. Ensure that the required service is registered in the + /// application's service collection. + protected IUserEntity UserInfo => HttpContext.RequestServices.GetRequiredService(); +} diff --git a/src/Presentation.WebApi/Common/ApiExceptionFilterAttribute.cs b/src/Presentation.WebApi/Common/ApiExceptionFilterAttribute.cs new file mode 100644 index 0000000..8c66264 --- /dev/null +++ b/src/Presentation.WebApi/Common/ApiExceptionFilterAttribute.cs @@ -0,0 +1,171 @@ +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; + +namespace Goodtocode.AgentFramework.Presentation.WebApi.Common; + +/// +/// Filter to handle ApiExceptionFilterAttribute +/// +public class ApiExceptionFilterAttribute : ExceptionFilterAttribute +{ + private readonly Dictionary> _exceptionHandlers; + + /// + /// ApiExceptionFilterAttribute including ValidationException, NotFoundException, UnauthorizedAccessException, + /// ForbiddenAccessException + /// NotFoundException + /// + public ApiExceptionFilterAttribute() + { + // Register known exception types and handlers. + _exceptionHandlers = new Dictionary> + { + {typeof(CustomValidationException), HandleValidationException}, + {typeof(CustomNotFoundException), HandleNotFoundException}, + {typeof(UnauthorizedAccessException), HandleUnauthorizedAccessException}, + {typeof(CustomForbiddenAccessException), HandleForbiddenAccessException}, + {typeof(CustomConflictException), HandleConflictException} + }; + } + + /// + /// Handles OnException + /// + /// + public override void OnException(ExceptionContext context) + { + HandleException(context); + + base.OnException(context); + } + + private void HandleException(ExceptionContext context) + { + var type = context.Exception.GetType(); + if (_exceptionHandlers.TryGetValue(type, out var handler)) + { + handler.Invoke(context); + return; + } + + if (!context.ModelState.IsValid) + { + HandleInvalidModelStateException(context); + return; + } + + HandleUnknownException(context); + } + + private void HandleValidationException(ExceptionContext context) + { + var exception = context.Exception as CustomValidationException; + + var details = new ValidationProblemDetails(exception?.Errors ?? new Dictionary()) + { + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1" + }; + + context.Result = new BadRequestObjectResult(details); + + context.ExceptionHandled = true; + } + + private static void HandleInvalidModelStateException(ExceptionContext context) + { + var details = new ValidationProblemDetails(context.ModelState) + { + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1" + }; + + context.Result = new BadRequestObjectResult(details); + + context.ExceptionHandled = true; + } + + private void HandleNotFoundException(ExceptionContext context) + { + var exception = context.Exception as CustomNotFoundException; + + var details = new ProblemDetails + { + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4", + Title = "The specified resource was not found.", + Detail = exception?.Message ?? string.Empty + }; + + context.Result = new NotFoundObjectResult(details); + + context.ExceptionHandled = true; + } + + private void HandleUnauthorizedAccessException(ExceptionContext context) + { + var details = new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Type = "https://tools.ietf.org/html/rfc7235#section-3.1" + }; + + context.Result = new ObjectResult(details) + { + StatusCode = StatusCodes.Status401Unauthorized + }; + + context.ExceptionHandled = true; + } + + private void HandleForbiddenAccessException(ExceptionContext context) + { + var details = new ProblemDetails + { + Status = StatusCodes.Status403Forbidden, + Title = "Forbidden", + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.3" + }; + + context.Result = new ObjectResult(details) + { + StatusCode = StatusCodes.Status403Forbidden + }; + + context.ExceptionHandled = true; + } + + private void HandleConflictException(ExceptionContext context) + { + var exception = context.Exception as CustomConflictException; + + var details = new ProblemDetails + { + Status = StatusCodes.Status409Conflict, + Title = "Conflict", + Detail = exception?.Message ?? string.Empty, + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.8" + }; + + context.Result = new ObjectResult(details) + { + StatusCode = StatusCodes.Status409Conflict + }; + + context.ExceptionHandled = true; + } + + private static void HandleUnknownException(ExceptionContext context) + { + var details = new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "An error occurred while processing your request.", + Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1" + }; + + context.Result = new ObjectResult(details) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + + context.ExceptionHandled = true; + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/ConfigureServices.cs b/src/Presentation.WebApi/ConfigureServices.cs new file mode 100644 index 0000000..8c9c423 --- /dev/null +++ b/src/Presentation.WebApi/ConfigureServices.cs @@ -0,0 +1,153 @@ +using Goodtocode.AgentFramework.Presentation.WebApi.Common; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.OpenApi; + +namespace Goodtocode.AgentFramework.Presentation.WebApi; + +/// +/// Presentation Layer WebApi Configuration +/// +public static class ConfigureServices +{ + /// + /// Determines if the current environment is "Local". + /// + /// The web host environment. + /// True if the environment is "Local"; otherwise, false. + public static bool IsLocal(this IWebHostEnvironment environment) + { + return environment.EnvironmentName.Equals("Local", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Add Local Environment Configuration to mirror Development + /// + /// + public static void AddLocalEnvironment(this WebApplicationBuilder builder) + { + if (builder.Environment.IsLocal()) + { + builder.Configuration + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables(); + builder.WebHost.UseStaticWebAssets(); + } + } + + /// + /// Add WebUI Services + /// + /// + /// + public static IServiceCollection AddWebUIServices(this IServiceCollection services) + { + services.AddControllersWithViews(setupAction => + { + setupAction.Filters.Add( + new ProducesDefaultResponseTypeAttribute()); + + // ToDo: Setup Authentication with Bearer Token + setupAction.Filters.Add(new AuthorizeFilter(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())); + setupAction.Filters.Add(); + }) + .AddJsonOptions(opts => opts.JsonSerializerOptions.PropertyNamingPolicy = null); + + services.AddEndpointsApiExplorer(); + + services.Configure(options => { options.SuppressModelStateInvalidFilter = true; }); + + services.AddCors(c => + { + c.AddPolicy("AllowOrigin", + options => + options + .WithOrigins("https://localhost:7000") + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader()); + }); + + services.AddSwaggerGen(setupAction => + { + setupAction.AddSecurityDefinition("Bearer", + new OpenApiSecurityScheme + { + Description = "JWT Authorization header using the Bearer scheme.", + Type = SecuritySchemeType.Http, + Scheme = "bearer" + }); + + setupAction.MapType(() => + new OpenApiSchema + { + Type = JsonSchemaType.Number, + Format = "decimal" + }); + }); + services.ConfigureOptions(); + + + return services; + } + + /// + /// Swagger UI Configuration + /// + /// + /// Constructor + /// + /// + public class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) : IConfigureNamedOptions + { + private readonly IApiVersionDescriptionProvider _provider = provider; + + /// + /// OpenApi Configuration + /// + /// + public void Configure(SwaggerGenOptions options) + { + foreach (var description in _provider.ApiVersionDescriptions) + options.SwaggerDoc(description.GroupName, CreateVersionInfo(description)); + + var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + options.IncludeXmlComments(xmlPath); + } + + /// + /// OpenApi Configuration + /// + /// + /// + public void Configure(string? name, SwaggerGenOptions options) + { + Configure(options); + } + + private static OpenApiInfo CreateVersionInfo(ApiVersionDescription description) + { + var info = new OpenApiInfo + { + Title = $"Agent Framework Service ({Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")})", + Version = description.ApiVersion.ToString(), + Description = $"An API to interact with the Agent Framework", + Contact = new OpenApiContact + { + Email = "developers@cloudinacan.com", + Name = "Cloud in a Can" + }, + License = new OpenApiLicense + { + Name = "Proprietary License", + Url = new Uri("https://cloudinacan.com/licenses/ClosedSource") + } + }; + + if (description.IsDeprecated) info.Description += " This API version has been deprecated."; + + return info; + } + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/Generate-NswagClientCode.json b/src/Presentation.WebApi/Generate-NswagClientCode.json new file mode 100644 index 0000000..0cdc68e --- /dev/null +++ b/src/Presentation.WebApi/Generate-NswagClientCode.json @@ -0,0 +1,100 @@ +{ + "runtime": "Net100", + "defaultVariables": null, + "documentGenerator": { + "fromDocument": { + "url": "swagger/v1/swagger.json", + "output": null, + "newLineBehavior": "Auto" + } + }, + "codeGenerators": { + "openApiToCSharpClient": { + "clientBaseClass": null, + "configurationClass": null, + "generateClientClasses": true, + "suppressClientClassesOutput": false, + "generateClientInterfaces": false, + "suppressClientInterfacesOutput": false, + "clientBaseInterface": null, + "injectHttpClient": true, + "disposeHttpClient": true, + "protectedMethods": [], + "generateExceptionClasses": true, + "exceptionClass": "ApiException", + "wrapDtoExceptions": true, + "useHttpClientCreationMethod": false, + "httpClientType": "System.Net.Http.HttpClient", + "useHttpRequestMessageCreationMethod": false, + "useBaseUrl": true, + "generateBaseUrlProperty": true, + "generateSyncMethods": false, + "generatePrepareRequestAndProcessResponseAsAsyncMethods": false, + "exposeJsonSerializerSettings": false, + "clientClassAccessModifier": "public", + "typeAccessModifier": "public", + "propertySetterAccessModifier": "", + "generateNativeRecords": false, + "generateContractsOutput": false, + "contractsNamespace": null, + "contractsOutputFilePath": null, + "parameterDateTimeFormat": "s", + "parameterDateFormat": "yyyy-MM-dd", + "generateUpdateJsonSerializerSettingsMethod": true, + "useRequestAndResponseSerializationSettings": false, + "serializeTypeInformation": false, + "queryNullValue": "", + "className": "BackendApiClient", + "operationGenerationMode": "MultipleClientsFromOperationId", + "additionalNamespaceUsages": [], + "additionalContractNamespaceUsages": [], + "generateOptionalParameters": false, + "generateJsonMethods": false, + "enforceFlagEnums": false, + "parameterArrayType": "System.Collections.Generic.IEnumerable", + "parameterDictionaryType": "System.Collections.Generic.IDictionary", + "responseArrayType": "System.Collections.Generic.ICollection", + "responseDictionaryType": "System.Collections.Generic.IDictionary", + "wrapResponses": false, + "wrapResponseMethods": [], + "generateResponseClasses": true, + "responseClass": "SwaggerResponse", + "namespace": "Goodtocode.AgentFramework.Presentation.WebApi.Client", + "requiredPropertiesMustBeDefined": true, + "dateType": "System.DateTimeOffset", + "jsonConverters": null, + "anyType": "object", + "dateTimeType": "System.DateTimeOffset", + "timeType": "System.TimeSpan", + "timeSpanType": "System.TimeSpan", + "arrayType": "System.Collections.Generic.ICollection", + "arrayInstanceType": "System.Collections.ObjectModel.Collection", + "dictionaryType": "System.Collections.Generic.IDictionary", + "dictionaryInstanceType": "System.Collections.Generic.Dictionary", + "arrayBaseType": "System.Collections.ObjectModel.Collection", + "dictionaryBaseType": "System.Collections.Generic.Dictionary", + "classStyle": "Poco", + "jsonLibrary": "SystemTextJson", + "generateDefaultValues": true, + "generateDataAnnotations": true, + "excludedTypeNames": [], + "excludedParameterNames": [], + "handleReferences": false, + "generateImmutableArrayProperties": false, + "generateImmutableDictionaryProperties": false, + "jsonSerializerSettingsTransformationMethod": null, + "inlineNamedArrays": false, + "inlineNamedDictionaries": false, + "inlineNamedTuples": true, + "inlineNamedAny": false, + "generateDtoTypes": true, + "generateOptionalPropertiesAsNullable": false, + "generateNullableReferenceTypes": false, + "templateDirectory": null, + "serviceHost": null, + "serviceSchemes": null, + "output": "../Presentation.Blazor/Clients/BackendApiClient.g.cs", + "newLineBehavior": "Auto" + } + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/Generate-NswagClientCode.ps1 b/src/Presentation.WebApi/Generate-NswagClientCode.ps1 new file mode 100644 index 0000000..4774e4c --- /dev/null +++ b/src/Presentation.WebApi/Generate-NswagClientCode.ps1 @@ -0,0 +1,50 @@ +#################################################################################### +# To execute +# 1. In powershell, set security polilcy for this script: +# Set-ExecutionPolicy Unrestricted -Scope Process -Force +# 2. Change directory to the script folder: +# CD C:\Scripts (wherever your script is) +# 3. In powershell, run script: +# .\Generate-NswagClientCode.ps1 +# Imperva Swagger: https://docs.imperva.com/bundle/cloud-application-security/page/cloud-v1-api-definition.htm +#################################################################################### + +param ( + [string]$SwaggerJsonPath = 'swagger', + [string]$ApiAssembly = 'bin\Debug\net10.0\Goodtocode.AgentFramework.Presentation.WebApi.dll', + [string]$ApiVersion = 'v1', + [string]$ClientPathFile = '../Presentation.Blazor/Clients/BackendApiClient.g.cs', + [string]$ClientNamespace = 'Goodtocode.AgentFramework.Presentation.WebApi.Client' +) +#################################################################################### +Set-ExecutionPolicy Unrestricted -Scope Process -Force +$VerbosePreference = 'SilentlyContinue' # 'Continue' +#################################################################################### + +$swaggerJsonPathFile = "$SwaggerJsonPath/$ApiVersion/swagger.json" +dotnet new tool-manifest --force +$env:ASPNETCORE_ENVIRONMENT = "Development" +$env:OpenAI__ApiKey = "123" +if (!(Test-Path -Path "$SwaggerJsonPath/$ApiVersion")) { + New-Item -ItemType Directory -Path "$SwaggerJsonPath/$ApiVersion" | Out-Null +} + +$swashVersion = "10.1.0" + +dotnet add package Swashbuckle.AspNetCore --version $swashVersion +dotnet restore +dotnet build --configuration Debug + +dotnet tool install swashbuckle.aspnetcore.cli --local --version $swashVersion +dotnet swagger tofile --output $swaggerJsonPathFile $ApiAssembly $ApiVersion + +if (Test-Path -Path $swaggerJsonPathFile) { + Write-Host "swagger.json generated successfully with OpenAPI 3.0.0." +} +else { + Write-Error "swagger.json was not generated. Please check for build errors or missing dependencies." + exit 1 +} + +dotnet tool install nswag.consolecore --local +dotnet nswag run Generate-NswagClientCode.json \ No newline at end of file diff --git a/src/Presentation.WebApi/GlobalUsings.cs b/src/Presentation.WebApi/GlobalUsings.cs new file mode 100644 index 0000000..5adfa3f --- /dev/null +++ b/src/Presentation.WebApi/GlobalUsings.cs @@ -0,0 +1,9 @@ +global using Asp.Versioning; +global using Asp.Versioning.ApiExplorer; +global using Goodtocode.Mediator; +global using Goodtocode.Validation; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.Mvc.Filters; +global using Microsoft.Extensions.Options; +global using Swashbuckle.AspNetCore.SwaggerGen; +global using System.Reflection; \ No newline at end of file diff --git a/src/Presentation.WebApi/Image/AdminImageController.cs b/src/Presentation.WebApi/Image/AdminImageController.cs new file mode 100644 index 0000000..c0f20a6 --- /dev/null +++ b/src/Presentation.WebApi/Image/AdminImageController.cs @@ -0,0 +1,170 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Core.Application.Image; +using Goodtocode.AgentFramework.Presentation.WebApi.Common; +using Microsoft.AspNetCore.Authorization; + +namespace Goodtocode.AgentFramework.Presentation.WebApi.Image; + +/// +/// Text Image endpoints to create a chat, continue a chat, delete a chat and retrieve chat history +/// +[ApiController] +[ApiConventionType(typeof(DefaultApiConventions))] +[Route("api/v{version:apiVersion}/admin/images")] +[ApiVersion("1.0")] +[Authorize] +public class AdminImageController : ApiControllerBase +{ + /// Get Text Image with history + /// + /// Sample request: + /// + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + /// "api-version": 1.0 + /// + /// + /// TextImageDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// AuthorKey: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet("{id}", Name = "GetTextImageQuery")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Get(Guid id) + { + return await Mediator.Send(new GetTextImageQuery + { + Id = id + }); + } + + /// Get All Text Images Query + /// + /// Sample request: + /// + /// "StartDate": "2024-06-01T00:00:00Z" + /// "EndDate": "2024-12-01T00:00:00Z" + /// "api-version": 1.0 + /// + /// + /// + /// TextImageDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Timestamp: "2024-06-03T11:21:00Z" + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet(Name = "GetTextImagesQuery")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetAll() + { + return await Mediator.Send(new GetTextImagesQuery()); + } + + /// Get Text Images Paginated Query + /// + /// Sample request: + /// + /// "StartDate": "2024-06-01T00:00:00Z" + /// "EndDate": "2024-12-01T00:00:00Z" + /// "PageNumber": 1 + /// "PageSize" : 10 + /// "api-version": 1.0 + /// + /// + /// + /// TextImageDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet("Paginated", Name = "GetTextImagesPaginatedQuery")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetTextImagesPaginatedQuery([FromQuery] GetTextImagesPaginatedQuery query) + { + return await Mediator.Send(query); + } + + /// + /// Creates new Image from Text + /// + /// + /// Sample request: + /// + /// HttpPost Body + /// { + /// "Id": 00000000-0000-0000-0000-000000000000, + /// "Message": "Hi, I am interested in learning about Agent Framework." + /// } + /// + /// "version": 1.0 + /// + /// + /// + /// TextImageDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpPost(Name = "CreateTextToImageCommand")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Post(CreateTextToImageCommand command) + { + var response = await Mediator.Send(command); + return CreatedAtAction(nameof(Get), new { response.Id }, response); + } + + /// Remove Image Command + /// + /// Sample request: + /// + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + /// "api-version": 1.0 + /// + /// + /// NoContent + [HttpDelete("{id}", Name = "RemoveTextImageCommand")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [ProducesDefaultResponseType] + public async Task Delete(Guid id) + { + await Mediator.Send(new DeleteTextImageCommand() { Id = id }); + + return NoContent(); + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/Presentation.WebApi.csproj b/src/Presentation.WebApi/Presentation.WebApi.csproj new file mode 100644 index 0000000..08a636c --- /dev/null +++ b/src/Presentation.WebApi/Presentation.WebApi.csproj @@ -0,0 +1,40 @@ + + + Goodtocode.AgentFramework.Presentation.WebApi + Goodtocode.AgentFramework.Presentation.WebApi + 1.0.0 + net10.0 + enable + enable + True + README.md + bf5b92ab-f3fe-4af6-b299-583240397247 + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Presentation.WebApi/Program.cs b/src/Presentation.WebApi/Program.cs new file mode 100644 index 0000000..e3ae2aa --- /dev/null +++ b/src/Presentation.WebApi/Program.cs @@ -0,0 +1,72 @@ +using Azure.Monitor.OpenTelemetry.AspNetCore; +using Goodtocode.AgentFramework.Core.Application; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework; +using Goodtocode.AgentFramework.Infrastructure.SqlServer; +using Goodtocode.AgentFramework.Presentation.WebApi; +using Goodtocode.AgentFramework.Presentation.WebApi.Auth; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +var builder = WebApplication.CreateBuilder(args); + +builder.AddLocalEnvironment(); + +builder.Services.AddAuthenticationWithRoles(builder.Configuration); +builder.Services.AddApplicationServices(); +builder.Services.AddDbContextServices(builder.Configuration); +builder.Services.AddAgentFrameworkOpenAIServices(builder.Configuration); +builder.Services.AddWebUIServices(); +builder.Services.AddHealthChecks(); + +BuildApiVerAndApiExplorer(builder); + +builder.Services.AddOpenTelemetry().UseAzureMonitor(options => +{ + options.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"]; +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment() || app.Environment.IsLocal()) +{ + app.UseSwagger(); + UseSwaggerUiConfigs(); +} + +app.UseRouting(); +app.UseHealthChecks("/health"); +app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.UseCors("AllowOrigin"); +app.MapControllers(); +app.Run(); + +void UseSwaggerUiConfigs() +{ + var providers = app.Services.GetService(); + + app.UseSwaggerUI(options => + { + if (providers == null) return; + foreach (var description in providers.ApiVersionDescriptions) + options.SwaggerEndpoint($"../swagger/{description.GroupName}/swagger.json", + description.GroupName.ToUpperInvariant()); + }); +} + +void BuildApiVerAndApiExplorer(WebApplicationBuilder webApplicationBuilder) +{ + webApplicationBuilder.Services.AddApiVersioning(setup => + { + setup.DefaultApiVersion = new ApiVersion(1, 0); + setup.AssumeDefaultVersionWhenUnspecified = true; + setup.ReportApiVersions = true; + }) + .AddApiExplorer(setup => + { + setup.GroupNameFormat = "'v'VVV"; + setup.SubstituteApiVersionInUrl = true; + }); +} diff --git a/src/Presentation.WebApi/Properties/launchSettings.json b/src/Presentation.WebApi/Properties/launchSettings.json new file mode 100644 index 0000000..281a6e3 --- /dev/null +++ b/src/Presentation.WebApi/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:6061", + "sslPort": 44315, + "ASPNETCORE_ENVIRONMENT": "Local" + } + }, + "profiles": { + "SelfHost": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Local" + }, + "applicationUrl": "https://localhost:6075;http://localhost:6065" + } + } +} diff --git a/src/Presentation.WebApi/Properties/serviceDependencies.json b/src/Presentation.WebApi/Properties/serviceDependencies.json new file mode 100644 index 0000000..1051177 --- /dev/null +++ b/src/Presentation.WebApi/Properties/serviceDependencies.json @@ -0,0 +1,12 @@ +{ + "dependencies": { + "secrets1": { + "type": "secrets", + "dynamicId": null + }, + "appInsights1": { + "type": "appInsights", + "dynamicId": null + } + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/Properties/serviceDependencies.local.json b/src/Presentation.WebApi/Properties/serviceDependencies.local.json new file mode 100644 index 0000000..5854bba --- /dev/null +++ b/src/Presentation.WebApi/Properties/serviceDependencies.local.json @@ -0,0 +1,12 @@ +{ + "dependencies": { + "secrets1": { + "type": "secrets.user", + "dynamicId": null + }, + "appInsights1": { + "type": "appInsights.sdk", + "dynamicId": null + } + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/TextGeneration/AdminTextGenerationController.cs b/src/Presentation.WebApi/TextGeneration/AdminTextGenerationController.cs new file mode 100644 index 0000000..e34165f --- /dev/null +++ b/src/Presentation.WebApi/TextGeneration/AdminTextGenerationController.cs @@ -0,0 +1,186 @@ +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Core.Application.TextGeneration; +using Goodtocode.AgentFramework.Presentation.WebApi.Common; +using Microsoft.AspNetCore.Authorization; + +namespace Goodtocode.AgentFramework.Presentation.WebApi.TextGeneration; + +/// +/// Text Generation endpoints to create a chat, continue a chat, delete a chat and retrieve chat history +/// +[ApiController] +[ApiConventionType(typeof(DefaultApiConventions))] +[Route("api/v{version:apiVersion}/admin/text")] +[ApiVersion("1.0")] +[Authorize] +public class AdminTextGenerationController : ApiControllerBase +{ + /// Get Text Generation session with history + /// + /// Sample request: + /// + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + /// "api-version": 1.0 + /// + /// + /// + /// TextPromptDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// AuthorKey: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet("{id}", Name = "GetTextPromptQuery")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Get(Guid id) + { + return await Mediator.Send(new GetTextPromptQuery + { + Id = id + }); + } + + /// Get All Text Generations Query + /// + /// Sample request: + /// + /// "StartDate": "2024-06-01T00:00:00Z" + /// "EndDate": "2024-12-01T00:00:00Z" + /// "api-version": 1.0 + /// + /// + /// + /// TextPromptDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Timestamp: "2024-06-03T11:21:00Z" + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet(Name = "GetTextPromptsQuery")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetAll() + { + return await Mediator.Send(new GetTextPromptsQuery()); + } + + /// Get Text Generations Paginated Query + /// + /// Sample request: + /// + /// "StartDate": "2024-06-01T00:00:00Z" + /// "EndDate": "2024-12-01T00:00:00Z" + /// "PageNumber": 1 + /// "PageSize" : 10 + /// "api-version": 1.0 + /// + /// + /// + /// TextPromptDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpGet("Paginated", Name = "GetTextPromptsPaginatedQuery")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetTextPromptsPaginatedQuery([FromQuery] GetTextPromptsPaginatedQuery query) + { + return await Mediator.Send(query); + } + + /// + /// Creates new Text Generation session + /// + /// + /// Types of Text Generation are: + /// 1. Generic Prompt: A broad or open-ended request for content. + /// - Example Prompt: “Write a short story.” + /// - Example Response: “Once upon a time, in a quaint village, there lived a curious cat named Whiskers…” + /// 2. Specific Prompt: A prompt with clear instructions or a specific topic. + /// - Example Prompt: “Describe the process of photosynthesis.” + /// - Example Response: “Photosynthesis is the process by which plants convert sunlight into energy, using chlorophyll in their leaves…” + /// 3. Visual Prompt: A prompt related to an image or visual content. + /// - Example Prompt: “Describe this image: ‘A serene sunset over the ocean.’” + /// - Example Response: “The sun dipped below the horizon, casting hues of orange and pink across the calm waters…” + /// 4. Role-Based Prompt: A prompt where you assume a specific role or context. + /// - Example Prompt: “Act as a travel guide.Describe the beauty of the Swiss Alps.” + /// - Example Response: “Welcome to the majestic Swiss Alps! Snow-capped peaks, pristine lakes, and charming villages await…” + /// 5. Output Format Specification: A prompt specifying the desired output format. + /// - Example Prompt: “Summarize the key findings of the research paper.” + /// - Example Response: “The paper discusses novel algorithms for optimizing neural network training, achieving faster convergence…” + /// Sample request: + /// + /// HttpPost Body + /// { + /// "Id": 00000000-0000-0000-0000-000000000000, + /// "Message": "Hi, I am interested in learning about Agent Framework." + /// } + /// + /// "version": 1.0 + /// + /// + /// + /// TextPromptDto + /// { Id: 1efb5e99-3a78-43df-a512-7d8ff498499e + /// ActorId: 4dfb5e99-3a78-43df-a512-7d8ff498499e + /// Messages: [ + /// { + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e, + /// "Content": "Certainly! Agent Framework is a great framework for AI.", + /// } + /// }] + /// + [HttpPost(Name = "CreateTextPromptCommand")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Post(CreateTextPromptCommand command) + { + var response = await Mediator.Send(command); + return CreatedAtAction(nameof(Get), new { response.Id }, response); + } + + /// Remove TextGeneration Command + /// + /// Sample request: + /// + /// "Id": 60fb5e99-3a78-43df-a512-7d8ff498499e + /// "api-version": 1.0 + /// + /// + /// NoContent + [HttpDelete("{id}", Name = "RemoveTextPromptCommand")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [ProducesDefaultResponseType] + public async Task Delete(Guid id) + { + await Mediator.Send(new DeleteTextPromptCommand() { Id = id }); + + return NoContent(); + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/appsettings.Development.json b/src/Presentation.WebApi/appsettings.Development.json new file mode 100644 index 0000000..da5f89e --- /dev/null +++ b/src/Presentation.WebApi/appsettings.Development.json @@ -0,0 +1,31 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "ApplicationInsights": { + "ConnectionString": "" + }, + "ConnectionStrings": { + "DefaultConnection": "" + }, + "AllowedHosts": "*", + "EntraExternalId": { + "Instance": "", + "TenantId": "", + "ClientId": "", + "ValidateAuthority": true + }, + "OpenAI": { + "ChatCompletionModelId": "gpt-4.1-nano", + "TextGenerationModelId": "gpt-3.5-turbo-instruct", + "TextEmbeddingModelId": "text-embedding-3-small", + "TextModerationModelId": "text-moderation-latest", + "ImageModelId": "dall-e-3", + "AudioModelId": "tts-1", + "ApiKey": "" + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/appsettings.Production.json b/src/Presentation.WebApi/appsettings.Production.json new file mode 100644 index 0000000..da5f89e --- /dev/null +++ b/src/Presentation.WebApi/appsettings.Production.json @@ -0,0 +1,31 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "ApplicationInsights": { + "ConnectionString": "" + }, + "ConnectionStrings": { + "DefaultConnection": "" + }, + "AllowedHosts": "*", + "EntraExternalId": { + "Instance": "", + "TenantId": "", + "ClientId": "", + "ValidateAuthority": true + }, + "OpenAI": { + "ChatCompletionModelId": "gpt-4.1-nano", + "TextGenerationModelId": "gpt-3.5-turbo-instruct", + "TextEmbeddingModelId": "text-embedding-3-small", + "TextModerationModelId": "text-moderation-latest", + "ImageModelId": "dall-e-3", + "AudioModelId": "tts-1", + "ApiKey": "" + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/appsettings.json b/src/Presentation.WebApi/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Presentation.WebApi/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Presentation.WebApi/appsettings.local.json b/src/Presentation.WebApi/appsettings.local.json new file mode 100644 index 0000000..b6970fb --- /dev/null +++ b/src/Presentation.WebApi/appsettings.local.json @@ -0,0 +1,31 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "ApplicationInsights": { + "ConnectionString": "InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=applicationinsights.azure.com" + }, + "ConnectionStrings": { + "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=AgentFramework;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" + }, + "AllowedHosts": "*", + "EntraExternalId": { + "Instance": "", + "TenantId": "", + "ClientId": "", + "ValidateAuthority": true + }, + "OpenAI": { + "ChatCompletionModelId": "gpt-4.1-nano", + "TextGenerationModelId": "gpt-3.5-turbo-instruct", + "TextEmbeddingModelId": "text-embedding-3-small", + "TextModerationModelId": "text-moderation-latest", + "ImageModelId": "dall-e-3", + "AudioModelId": "tts-1", + "ApiKey": "" + } +} \ No newline at end of file diff --git a/src/Presentation.WebApi/dotnet-tools.json b/src/Presentation.WebApi/dotnet-tools.json new file mode 100644 index 0000000..b0e38ab --- /dev/null +++ b/src/Presentation.WebApi/dotnet-tools.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "isRoot": true, + "tools": {} +} \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Actor/CreateActorCommand.feature b/src/Tests.Specs.Integration/Actor/CreateActorCommand.feature new file mode 100644 index 0000000..833a619 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/CreateActorCommand.feature @@ -0,0 +1,21 @@ +@createAuthorCommand +Feature: Create Actor Command +As a owner +When I want to save a new Actor +Then I should see the Actor created with the initial response + +Scenario: Create Actor + Given I have a def "" + And I have a name "" + And I have a Actor id "" + And I have a External id "" + And I have a Email "" + And The Actor exists "" + When I create a actor + Then I see the Actor created with the initial response "" + And if the response has validation issues I see the "" in the response + +Examples: + | def | response | responseErrors | id | ownerId | exists | name | email | + | success | Success | | 00000000-0000-0000-0000-000000000000 | 938d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | John Doe | jdoe@goodtocode.com | + | already exists | Error | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | 938d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | Jane Doe | jane@goodtocode.com | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Actor/CreateActorCommand.feature.cs b/src/Tests.Specs.Integration/Actor/CreateActorCommand.feature.cs new file mode 100644 index 0000000..61c9a22 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/CreateActorCommand.feature.cs @@ -0,0 +1,192 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Actor +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class CreateActorCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "createAuthorCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Actor", "Create Actor Command", "As a owner\r\nWhen I want to save a new Actor\r\nThen I should see the Actor created " + + "with the initial response", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "CreateActorCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Actor/CreateActorCommand.feature.ndjson", 4); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Create Actor")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Create Actor")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Create Actor Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("createAuthorCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "00000000-0000-0000-0000-000000000000", "938d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "John Doe", "jdoe@goodtocode.com", "0", null, DisplayName="CreateActor(success,Success,,00000000-0000-0000-0000-000000000000,938d8e7f-f18f-4" + + "a8e-8b3c-3b6a6889fed9,false,John Doe,jdoe@goodtocode.com,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("already exists", "Error", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "938d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "Jane Doe", "jane@goodtocode.com", "1", null, DisplayName="CreateActor(already exists,Error,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,938d8e7f-f" + + "18f-4a8e-8b3c-3b6a6889fed9,true,Jane Doe,jane@goodtocode.com,1)")] + public async global::System.Threading.Tasks.Task CreateActor(string def, string response, string responseErrors, string id, string ownerId, string exists, string name, string email, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("ownerId", ownerId); + argumentsOfScenario.Add("exists", exists); + argumentsOfScenario.Add("name", name); + argumentsOfScenario.Add("email", email); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Create Actor", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a name \"{0}\"", name), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a Actor id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a External id \"{0}\"", ownerId), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("I have a Email \"{0}\"", email), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("The Actor exists \"{0}\"", exists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.WhenAsync("I create a actor", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 15 + await testRunner.ThenAsync(string.Format("I see the Actor created with the initial response \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 16 + await testRunner.AndAsync(string.Format("if the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Actor/CreateActorCommandStepDefinitions.cs b/src/Tests.Specs.Integration/Actor/CreateActorCommandStepDefinitions.cs new file mode 100644 index 0000000..e57baaf --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/CreateActorCommandStepDefinitions.cs @@ -0,0 +1,105 @@ +using Goodtocode.AgentFramework.Core.Application.Actor; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Specs.Integration.Actor; + +[Binding] +[Scope(Tag = "createAuthorCommand")] +public class CreateActorCommandStepDefinitions : TestBase +{ + private string _name = string.Empty; + private Guid _id; + private bool _exists; + private Guid _ownerId = Guid.NewGuid(); + private readonly Guid _tenantId = Guid.NewGuid(); + private string _email = string.Empty; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a name ""([^""]*)""")] + public void GivenIHaveAName(string name) + { + _name = name; + } + + [Given("I have a Email {string}")] + public void GivenIHaveAEmail(string email) + { + _email = email; + } + + [Given(@"I have a Actor id ""([^""]*)""")] + public void GivenIHaveAAuthorId(string id) + { + _id = Guid.Parse(id); + } + + [Given("I have a External id {string}")] + public void GivenIHaveAExternalId(string ownerId) + { + _ownerId = Guid.Parse(ownerId); + } + + [Given(@"The Actor exists ""([^""]*)""")] + public void GivenTheAuthorExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I create a actor")] + public async Task WhenICreateAAuthor() + { + if (_exists) + { + var actor = ActorEntity.Create(_id, _ownerId, _tenantId, "John", "Doe", "jdoe@goodtocode.com"); + context.Actors.Add(actor); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new CreateActorCommand() + { + Id = _id, + OwnerId = _ownerId, + TenantId = _tenantId, + FirstName = _name.Split(" ").FirstOrDefault(), + LastName = _name.Split(" ").LastOrDefault(), + Email = _email + }; + + var validator = new CreateActorCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + { + try + { + var handler = new CreateAuthorCommandHandler(context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"I see the Actor created with the initial response ""([^""]*)""")] + public void ThenISeeTheAuthorCreatedWithTheInitialResponse(string response) + { + HandleHasResponseType(response); + } + + [Then(@"if the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + +} \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Actor/DeleteActorCommand.feature b/src/Tests.Specs.Integration/Actor/DeleteActorCommand.feature new file mode 100644 index 0000000..83b3229 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/DeleteActorCommand.feature @@ -0,0 +1,18 @@ +@deleteAuthorCommand +Feature: Delete Actor Command +As a Actor owner +When I select a Actor +I can delete the Actor + +Scenario: Delete Actor + Given I have a def "" + And I have a actor id"" + And The actor exists "" + When I delete the actor + Then The response is "" + And If the response has validation issues I see the "" in the response + +Examples: + | def | response | responseErrors | id | exists | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Actor/DeleteActorCommand.feature.cs b/src/Tests.Specs.Integration/Actor/DeleteActorCommand.feature.cs new file mode 100644 index 0000000..556b917 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/DeleteActorCommand.feature.cs @@ -0,0 +1,177 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Actor +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class DeleteActorCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "deleteAuthorCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Actor", "Delete Actor Command", "As a Actor owner\r\nWhen I select a Actor\r\nI can delete the Actor", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "DeleteActorCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Actor/DeleteActorCommand.feature.ndjson", 4); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Delete Actor")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Delete Actor")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Delete Actor Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("deleteAuthorCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="DeleteActor(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="DeleteActor(not found,NotFound,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false,1)")] + public async global::System.Threading.Tasks.Task DeleteActor(string def, string response, string responseErrors, string id, string exists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("exists", exists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Delete Actor", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a actor id\"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("The actor exists \"{0}\"", exists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I delete the actor", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Actor/DeleteActorCommandStepDefinitions.cs b/src/Tests.Specs.Integration/Actor/DeleteActorCommandStepDefinitions.cs new file mode 100644 index 0000000..52a0f5c --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/DeleteActorCommandStepDefinitions.cs @@ -0,0 +1,76 @@ +using Goodtocode.AgentFramework.Core.Application.Actor; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Specs.Integration.Actor +{ + [Binding] + [Scope(Tag = "deleteAuthorCommand")] + public class DeleteActorCommandStepDefinitions : TestBase + { + private bool _exists; + private Guid _id; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a actor id""([^""]*)""")] + public void GivenIHaveAAuthorId(string id) + { + Guid.TryParse(id, out _id).ShouldBeTrue(); + } + + [Given(@"The actor exists ""([^""]*)""")] + public void GivenTheAuthorExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I delete the actor")] + public async Task WhenIDeleteTheAuthor() + { + if (_exists) + { + var actor = ActorEntity.Create(_id, _id, Guid.NewGuid(), "John", "Doe", "jdoe@goodtocode.com"); + context.Actors.Add(actor); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new DeleteActorByOwnerIdCommand() + { + OwnerId = _id + }; + + var validator = new DeleteActorByOwnerIdCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + try + { + var handler = new DeleteAuthorByOwnerIdCommandHandler(context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + } +} diff --git a/src/Tests.Specs.Integration/Actor/GetActorByExternalIdQuery.feature b/src/Tests.Specs.Integration/Actor/GetActorByExternalIdQuery.feature new file mode 100644 index 0000000..945e8c0 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorByExternalIdQuery.feature @@ -0,0 +1,19 @@ +@getAuthorByOwnerIdQuery +Feature: Get Actor By External Id Query +As a actor owner +When I select an existing Actor +I can see the actor detail + +Scenario: Get Actor By External Id + Given I have a definition "" + And I have a Actor External Id + And the Actor exists "" + When I get the Actor + Then The response is "" + And If the response has validation issues I see the "" in the response + And If the response is successful the response has a Id + +Examples: + | def | response | responseErrors | exists | + | success | Success | | true | + | not found | NotFound | | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Actor/GetActorByExternalIdQuery.feature.cs b/src/Tests.Specs.Integration/Actor/GetActorByExternalIdQuery.feature.cs new file mode 100644 index 0000000..9b1c617 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorByExternalIdQuery.feature.cs @@ -0,0 +1,179 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Actor +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetActorByExternalIdQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getAuthorByOwnerIdQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Actor", "Get Actor By External Id Query", "As a actor owner\r\nWhen I select an existing Actor\r\nI can see the actor detail", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetActorByExternalIdQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Actor/GetActorByExternalIdQuery.feature.ndjson", 4); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get Actor By External Id")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get Actor By External Id")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Actor By External Id Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getAuthorByOwnerIdQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "true", "0", null, DisplayName="GetActorByExternalId(success,Success,,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "false", "1", null, DisplayName="GetActorByExternalId(not found,NotFound,,false,1)")] + public async global::System.Threading.Tasks.Task GetActorByExternalId(string def, string response, string responseErrors, string exists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("exists", exists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get Actor By External Id", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync("I have a Actor External Id", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("the Actor exists \"{0}\"", exists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I get the Actor", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync("If the response is successful the response has a Id", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Actor/GetActorByExternalIdQueryStepDefinitions.cs b/src/Tests.Specs.Integration/Actor/GetActorByExternalIdQueryStepDefinitions.cs new file mode 100644 index 0000000..6fca5e6 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorByExternalIdQueryStepDefinitions.cs @@ -0,0 +1,81 @@ +using Goodtocode.AgentFramework.Core.Application.Actor; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Specs.Integration.Actor; + +[Binding] +[Scope(Tag = "getAuthorByOwnerIdQuery")] +public class GetActorByOwnerIdQueryStepDefinitions : TestBase +{ + private bool _exists; + private ActorDto? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given("I have a Actor External Id")] + public void GivenIHaveAAuthorExternalId() + { + userInfo.OwnerId.ShouldNotBeEmpty(); + } + + [Given(@"the Actor exists ""([^""]*)""")] + public void GivenITheAuthorExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I get the Actor")] + public async Task WhenIGetAAuthor() + { + if (_exists) + { + var actor = ActorEntity.Create(userInfo.OwnerId, userInfo.OwnerId, Guid.NewGuid(), "John", "Doe", "jdoe@goodtocode.com"); + context.Actors.Add(actor); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetMyActorQuery() + { + UserInfo = userInfo + }; + + var validator = new GetMyActorQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetAuthorByOwnerIdQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"If the response is successful the response has a Id")] + public void ThenIfTheResponseIsSuccessfulTheResponseHasAKey() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Id.ShouldNotBeEmpty(); + } +} diff --git a/src/Tests.Specs.Integration/Actor/GetActorChatSessionQuery.feature b/src/Tests.Specs.Integration/Actor/GetActorChatSessionQuery.feature new file mode 100644 index 0000000..93f0a4c --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorChatSessionQuery.feature @@ -0,0 +1,20 @@ +@getAuthorChatSessionQuery +Feature: Get Actor Chat Session Query +As a chat user actor +When I select an existing chat session +I can get the chat history messages + +Scenario: Get actor chat session + Given I have a definition "" + And I have a actor id "" + And I have a chat session id "" + And I the chat session exists "" + When I get a chat session + Then The response is "" + And If the response has validation issues I see the "" in the response + And If the response is successful the response has a Id + +Examples: + | def | response | responseErrors | id | chatSessionId | chatSessionExists | + | success | Success | | 098d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | 045d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 023d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | 078d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | diff --git a/src/Tests.Specs.Integration/Actor/GetActorChatSessionQuery.feature.cs b/src/Tests.Specs.Integration/Actor/GetActorChatSessionQuery.feature.cs new file mode 100644 index 0000000..e4def53 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorChatSessionQuery.feature.cs @@ -0,0 +1,187 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Actor +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetActorChatSessionQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getAuthorChatSessionQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Actor", "Get Actor Chat Session Query", "As a chat user actor\r\nWhen I select an existing chat session\r\nI can get the chat " + + "history messages", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetActorChatSessionQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Actor/GetActorChatSessionQuery.feature.ndjson", 4); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get actor chat session")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get actor chat session")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Actor Chat Session Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getAuthorChatSessionQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "098d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "045d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="GetActorChatSession(success,Success,,098d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,045d8e7" + + "f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "023d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "078d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="GetActorChatSession(not found,NotFound,,023d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,078d" + + "8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false,1)")] + public async global::System.Threading.Tasks.Task GetActorChatSession(string def, string response, string responseErrors, string id, string chatSessionId, string chatSessionExists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("chatSessionId", chatSessionId); + argumentsOfScenario.Add("chatSessionExists", chatSessionExists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get actor chat session", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a actor id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a chat session id \"{0}\"", chatSessionId), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I the chat session exists \"{0}\"", chatSessionExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.WhenAsync("I get a chat session", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 13 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 15 + await testRunner.AndAsync("If the response is successful the response has a Id", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Actor/GetActorChatSessionQueryStepDefinitions.cs b/src/Tests.Specs.Integration/Actor/GetActorChatSessionQueryStepDefinitions.cs new file mode 100644 index 0000000..c805c10 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorChatSessionQueryStepDefinitions.cs @@ -0,0 +1,96 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Domain.Actor; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.Actor; + +[Binding] +[Scope(Tag = "getAuthorChatSessionQuery")] +public class GetActorChatSessionQueryStepDefinitions : TestBase +{ + private Guid _id; + private Guid _chatSessionid; + private bool _exists; + private ChatSessionDto? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"I have a actor id ""([^""]*)""")] + public void GivenIHaveAAuthorId(string id) + { + if (string.IsNullOrWhiteSpace(id)) return; + Guid.TryParse(id, out _id).ShouldBeTrue(); + } + + [Given(@"I have a chat session id ""([^""]*)""")] + public void GivenIHaveAChatSessionId(string chatSessionId) + { + if (string.IsNullOrWhiteSpace(chatSessionId)) return; + Guid.TryParse(chatSessionId, out _chatSessionid).ShouldBeTrue(); + } + + [Given(@"I the chat session exists ""([^""]*)""")] + public void GivenITheChatSessionExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I get a chat session")] + public async Task WhenIGetAChatSession() + { + if (_exists) + { + var actor = ActorEntity.Create(_id, userInfo.OwnerId, Guid.NewGuid(), "John", "Doe", "jdoe@goodtocode.com"); + context.Actors.Add(actor); + await context.SaveChangesAsync(CancellationToken.None); + var chatSession = ChatSessionEntity.Create(_chatSessionid, _id, "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetMyChatSessionQuery() + { + ChatSessionId = _chatSessionid, + UserInfo = userInfo + }; + + var validator = new GetMyChatSessionQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetAuthorChatSessionByOwnerIdQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"If the response is successful the response has a Id")] + public void ThenIfTheResponseIsSuccessfulTheResponseHasAId() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Id.ShouldNotBeEmpty(); + } +} diff --git a/src/Tests.Specs.Integration/Actor/GetActorChatSessionsPaginatedQuery.feature b/src/Tests.Specs.Integration/Actor/GetActorChatSessionsPaginatedQuery.feature new file mode 100644 index 0000000..7f89414 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorChatSessionsPaginatedQuery.feature @@ -0,0 +1,35 @@ +@getAuthorChatSessionsPaginatedQuery +Feature: Get Actor Chat Sessions Paginated Query +As an actor of chat sessions +When I get chat sessions by actor or by date range +I can get a paginated collection of chat sessions + +Scenario: Get actor chat sessions paginated + Given I have a definition "" + And Chat Sessions exist "" + And I have a Actor id "" + And I have a start date "" + And I have a end date "" + And chat sessions within the date range exists "" + And I have a page number "" + And I have a page size "" + When I get the chat sessions paginated + Then The response is "" + And If the response has validation issues I see the "" in the response + And The response has a collection of chat sessions + And Each chat session has a Key + And Each chat session has a Date greater than start date + And Each chat session has a Date less than end date + And The response has a Page Number + And The response has a Total Pages + And The response has a Total Count + + +Examples: + | def | response | responseErrors | id | startDate | endDate | exist | chatSessionsResultExists | pageNumber | pageSize | + | success no date range | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | | | true | true | 1 | 10 | + | success with date range | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | true | 1 | 10 | + | success with date range | Success | | 9c5f2b35-b380-44f8-8c71-c24a43b3fe63 | 2025-07-19T11:21:00Z | 2034-06-03T11:21:00Z | true | true | 1 | 10 | + | success empty results | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | | | false | false | 1 | 10 | + | bad request page number zero | BadRequest | PageNumber | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | | | false | false | 0 | 10 | + | bad request page size zero | BadRequest | PageSize | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | | | false | false | 1 | 0 | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Actor/GetActorChatSessionsPaginatedQuery.feature.cs b/src/Tests.Specs.Integration/Actor/GetActorChatSessionsPaginatedQuery.feature.cs new file mode 100644 index 0000000..2a33b9b --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorChatSessionsPaginatedQuery.feature.cs @@ -0,0 +1,229 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Actor +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetActorChatSessionsPaginatedQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getAuthorChatSessionsPaginatedQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Actor", "Get Actor Chat Sessions Paginated Query", "As an actor of chat sessions\r\nWhen I get chat sessions by actor or by date range\r" + + "\nI can get a paginated collection of chat sessions", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetActorChatSessionsPaginatedQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Actor/GetActorChatSessionsPaginatedQuery.feature.ndjson", 8); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get actor chat sessions paginated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get actor chat sessions paginated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Actor Chat Sessions Paginated Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getAuthorChatSessionsPaginatedQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success no date range", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "", "", "true", "true", "1", "10", "0", null, DisplayName="GetActorChatSessionsPaginated(success no date range,Success,,038d8e7f-f18f-4a8e-8" + + "b3c-3b6a6889fed9,,,true,true,1,10,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", "10", "1", null, DisplayName="GetActorChatSessionsPaginated(success with date range,Success,,038d8e7f-f18f-4a8e" + + "-8b3c-3b6a6889fed9,2024-06-01T11:21:00Z,2034-06-03T11:21:00Z,true,true,1,10,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "9c5f2b35-b380-44f8-8c71-c24a43b3fe63", "2025-07-19T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", "10", "2", null, DisplayName="GetActorChatSessionsPaginated(success with date range,Success,,9c5f2b35-b380-44f8" + + "-8c71-c24a43b3fe63,2025-07-19T11:21:00Z,2034-06-03T11:21:00Z,true,true,1,10,2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success empty results", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "", "", "false", "false", "1", "10", "3", null, DisplayName="GetActorChatSessionsPaginated(success empty results,Success,,038d8e7f-f18f-4a8e-8" + + "b3c-3b6a6889fed9,,,false,false,1,10,3)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request page number zero", "BadRequest", "PageNumber", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "", "", "false", "false", "0", "10", "4", null, DisplayName="GetActorChatSessionsPaginated(bad request page number zero,BadRequest,PageNumber," + + "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,,,false,false,0,10,4)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request page size zero", "BadRequest", "PageSize", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "", "", "false", "false", "1", "0", "5", null, DisplayName="GetActorChatSessionsPaginated(bad request page size zero,BadRequest,PageSize,038d" + + "8e7f-f18f-4a8e-8b3c-3b6a6889fed9,,,false,false,1,0,5)")] + public async global::System.Threading.Tasks.Task GetActorChatSessionsPaginated(string def, string response, string responseErrors, string id, string startDate, string endDate, string exist, string chatSessionsResultExists, string pageNumber, string pageSize, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("startDate", startDate); + argumentsOfScenario.Add("endDate", endDate); + argumentsOfScenario.Add("exist", exist); + argumentsOfScenario.Add("chatSessionsResultExists", chatSessionsResultExists); + argumentsOfScenario.Add("pageNumber", pageNumber); + argumentsOfScenario.Add("pageSize", pageSize); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get actor chat sessions paginated", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("Chat Sessions exist \"{0}\"", exist), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a Actor id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a start date \"{0}\"", startDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("I have a end date \"{0}\"", endDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("chat sessions within the date range exists \"{0}\"", chatSessionsResultExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("I have a page number \"{0}\"", pageNumber), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 15 + await testRunner.AndAsync(string.Format("I have a page size \"{0}\"", pageSize), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 16 + await testRunner.WhenAsync("I get the chat sessions paginated", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 17 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 18 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 19 + await testRunner.AndAsync("The response has a collection of chat sessions", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 20 + await testRunner.AndAsync("Each chat session has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 21 + await testRunner.AndAsync("Each chat session has a Date greater than start date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 22 + await testRunner.AndAsync("Each chat session has a Date less than end date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 23 + await testRunner.AndAsync("The response has a Page Number", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 24 + await testRunner.AndAsync("The response has a Total Pages", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 25 + await testRunner.AndAsync("The response has a Total Count", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Actor/GetActorChatSessionsPaginatedQueryStepDefinitions.cs b/src/Tests.Specs.Integration/Actor/GetActorChatSessionsPaginatedQueryStepDefinitions.cs new file mode 100644 index 0000000..109a26b --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorChatSessionsPaginatedQueryStepDefinitions.cs @@ -0,0 +1,179 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Core.Domain.Actor; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.Actor +{ + [Binding] + [Scope(Tag = "getAuthorChatSessionsPaginatedQuery")] + public class GetActorChatSessionsPaginatedQueryStepDefinitions : TestBase + { + private bool _exists; + private DateTime _startDate; + private DateTime _endDate; + private bool _withinDateRangeExists; + private int _pageNumber; + private int _pageSize; + private PaginatedList? _response; + private Guid _id; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"Chat Sessions exist ""([^""]*)""")] + public void GivenAuthorChatSessionsExist(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"I have a Actor id ""([^""]*)""")] + public void GivenIHaveAAuthorId(string authorId) + { + if (string.IsNullOrWhiteSpace(authorId)) return; + Guid.TryParse(authorId, out _id).ShouldBeTrue(); + } + + [Given(@"I have a start date ""([^""]*)""")] + public void GivenIHaveAStartDate(string startDate) + { + if (string.IsNullOrWhiteSpace(startDate)) return; + DateTime.TryParse(startDate, out _startDate).ShouldBeTrue(); + } + + [Given(@"I have a end date ""([^""]*)""")] + public void GivenIHaveAEndDate(string endDate) + { + if (string.IsNullOrWhiteSpace(endDate)) return; + DateTime.TryParse(endDate, out _endDate).ShouldBeTrue(); + } + + [Given(@"chat sessions within the date range exists ""([^""]*)""")] + public void GivenAuthorChatSessionsWithinTheDateRangeExists(string withinDateRangeExists) + { + bool.TryParse(withinDateRangeExists, out _withinDateRangeExists).ShouldBeTrue(); + } + + [Given(@"I have a page number ""([^""]*)""")] + public void GivenIHaveAPageNumber(string pageNumber) + { + int.TryParse(pageNumber, out _pageNumber).ShouldBeTrue(); + } + + [Given(@"I have a page size ""([^""]*)""")] + public void GivenIHaveAPageSize(string pageSize) + { + int.TryParse(pageSize, out _pageSize).ShouldBeTrue(); ; + } + + [When(@"I get the chat sessions paginated")] + public async Task WhenIGetTheAuthorChatSessionsPaginated() + { + if (_exists) + { + var actor = ActorEntity.Create(_id, userInfo.OwnerId, Guid.NewGuid(), "John", "Doe", "jdoe@goodtocode.com"); + context.Actors.Add(actor); + await context.SaveChangesAsync(CancellationToken.None); + var chatSession = ChatSessionEntity.Create(_id, _id, "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + for(var count = 0; count <= _pageSize; count++) + { + context.ChatMessages.Add(ChatMessageEntity.Create(Guid.NewGuid(), _id, ChatMessageRole.user, $"Message {count + 1}")); + chatSession.Messages.Add(ChatMessageEntity.Create(Guid.NewGuid(), _id, ChatMessageRole.user, $"Message {count + 1}")); + context.ChatMessages.Add(ChatMessageEntity.Create(Guid.NewGuid(), _id, ChatMessageRole.assistant, $"Response {count + 1}")); + chatSession.Messages.Add(ChatMessageEntity.Create(Guid.NewGuid(), _id, ChatMessageRole.assistant, $"Response {count + 1}")); + } + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetMyChatSessionsPaginatedQuery() + { + PageNumber = _pageNumber, + PageSize = _pageSize, + StartDate = _startDate == default ? null : _startDate, + EndDate = _endDate == default ? null : _endDate, + UserInfo = userInfo + }; + + var validator = new GetMyChatSessionsPaginatedQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetAuthorChatSessionsByExternalIdPaginatedQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"The response has a collection of chat sessions")] + public void ThenTheResponseHasACollectionOfAuthorChatSessions() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalCount.ShouldBe(_withinDateRangeExists == false ? 0 : _response.TotalCount); + } + + [Then(@"Each chat session has a Key")] + public void ThenEachChatSessionHasAKey() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Items.FirstOrDefault(x => x.Id == default).ShouldBeNull(); + } + + [Then(@"Each chat session has a Date greater than start date")] + public void ThenEachChatSessionHasADateGreaterThanStartDate() + { + if (responseType == CommandResponseType.Successful && _withinDateRangeExists) + _response?.Items.FirstOrDefault(x => _startDate == default || x.Timestamp > _startDate).ShouldNotBeNull(); + } + + [Then(@"Each chat session has a Date less than end date")] + public void ThenEachChatSessionHasADateLessThanEndDate() + { + if (responseType == CommandResponseType.Successful && _withinDateRangeExists) + _response?.Items.FirstOrDefault(x => _endDate == default || x.Timestamp < _endDate).ShouldNotBeNull(); + } + + [Then(@"The response has a Page Number")] + public void ThenTheResponseHasAPageNumber() + { + if (responseType != CommandResponseType.Successful) return; + _response?.PageNumber.Should(); + } + + [Then(@"The response has a Total Pages")] + public void ThenTheResponseHasATotalPages() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalPages.Should(); + } + + [Then(@"The response has a Total Count")] + public void ThenTheResponseHasATotalCount() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalCount.Should(); + } + } +} diff --git a/src/Tests.Specs.Integration/Actor/GetActorChatSessionsQuery.feature b/src/Tests.Specs.Integration/Actor/GetActorChatSessionsQuery.feature new file mode 100644 index 0000000..ff45c63 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorChatSessionsQuery.feature @@ -0,0 +1,27 @@ +@getAuthorChatSessionsQuery +Feature: Get Actor Chat Sessions Query +As an actor +When I query chat sessions optionally by date range +I get all sessions that fit the date range + +Scenario: Get actor chat sessions + Given I have a definition "" + And Chat Sessions exist "" + And chat sessions within the date range exists "" + And I have a Actor id "" + And I have a start date "" + And I have a end date "" + When I get the chat sessions + Then The response is "" + And If the response has validation issues I see the "" in the response + And The response has a collection of chat sessions + And Each chat session has a Key + And Each chat session has a Date greater than start date + And Each chat session has a Date less than end date + +Examples: + | def | response | responseErrors | id | startDate | endDate | exist | chatSessionsResultExists | + | success no date range | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | | | true | true | + | success with date range | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | true | + | success filtered results | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | false | + | success empty results | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | | | false | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Actor/GetActorChatSessionsQuery.feature.cs b/src/Tests.Specs.Integration/Actor/GetActorChatSessionsQuery.feature.cs new file mode 100644 index 0000000..3c4e3e3 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorChatSessionsQuery.feature.cs @@ -0,0 +1,208 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Actor +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetActorChatSessionsQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getAuthorChatSessionsQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Actor", "Get Actor Chat Sessions Query", "As an actor\r\nWhen I query chat sessions optionally by date range\r\nI get all sessi" + + "ons that fit the date range", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetActorChatSessionsQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Actor/GetActorChatSessionsQuery.feature.ndjson", 6); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get actor chat sessions")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get actor chat sessions")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Actor Chat Sessions Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getAuthorChatSessionsQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success no date range", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "", "", "true", "true", "0", null, DisplayName="GetActorChatSessions(success no date range,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6" + + "889fed9,,,true,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", null, DisplayName="GetActorChatSessions(success with date range,Success,,038d8e7f-f18f-4a8e-8b3c-3b6" + + "a6889fed9,2024-06-01T11:21:00Z,2034-06-03T11:21:00Z,true,true,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success filtered results", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "false", "2", null, DisplayName="GetActorChatSessions(success filtered results,Success,,038d8e7f-f18f-4a8e-8b3c-3b" + + "6a6889fed9,2024-06-01T11:21:00Z,2034-06-03T11:21:00Z,true,false,2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success empty results", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "", "", "false", "false", "3", null, DisplayName="GetActorChatSessions(success empty results,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6" + + "889fed9,,,false,false,3)")] + public async global::System.Threading.Tasks.Task GetActorChatSessions(string def, string response, string responseErrors, string id, string startDate, string endDate, string exist, string chatSessionsResultExists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("startDate", startDate); + argumentsOfScenario.Add("endDate", endDate); + argumentsOfScenario.Add("exist", exist); + argumentsOfScenario.Add("chatSessionsResultExists", chatSessionsResultExists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get actor chat sessions", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("Chat Sessions exist \"{0}\"", exist), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("chat sessions within the date range exists \"{0}\"", chatSessionsResultExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a Actor id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("I have a start date \"{0}\"", startDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("I have a end date \"{0}\"", endDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.WhenAsync("I get the chat sessions", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 15 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 16 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 17 + await testRunner.AndAsync("The response has a collection of chat sessions", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 18 + await testRunner.AndAsync("Each chat session has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 19 + await testRunner.AndAsync("Each chat session has a Date greater than start date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 20 + await testRunner.AndAsync("Each chat session has a Date less than end date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Actor/GetActorChatSessionsQueryStepDefinitions.cs b/src/Tests.Specs.Integration/Actor/GetActorChatSessionsQueryStepDefinitions.cs new file mode 100644 index 0000000..67333a3 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorChatSessionsQueryStepDefinitions.cs @@ -0,0 +1,132 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Domain.Actor; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.Actor; + +[Binding] +[Scope(Tag = "getAuthorChatSessionsQuery")] +public class GetActorChatSessionsQueryStepDefinitions : TestBase +{ + private bool _exists; + private bool _withinDateRangeExists; + private DateTime _endDate; + private DateTime _startDate; + private ICollection? _response; + private Guid _id; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"Chat Sessions exist ""([^""]*)""")] + public void GivenChatSessionsExist(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"chat sessions within the date range exists ""([^""]*)""")] + public void GivenChatSessionsWithinTheDateRangeExists(string withinDateRangeExists) + { + bool.TryParse(withinDateRangeExists, out _withinDateRangeExists).ShouldBeTrue(); + } + + [Given(@"I have a Actor id ""([^""]*)""")] + public void GivenIHaveAAuthorId(string authorId) + { + if (string.IsNullOrWhiteSpace(authorId)) return; + Guid.TryParse(authorId, out _id).ShouldBeTrue(); + } + + [Given(@"I have a start date ""([^""]*)""")] + public void GivenIHaveAStartDate(string startDate) + { + if (string.IsNullOrWhiteSpace(startDate)) return; + DateTime.TryParse(startDate, out _startDate).ShouldBeTrue(); + _startDate = DateTime.UtcNow.AddMinutes(_withinDateRangeExists ? -1 : 1); //Handle for desired not-found scenarios + } + + [Given(@"I have a end date ""([^""]*)""")] + public void GivenIHaveAEndDate(string endDate) + { + if (string.IsNullOrWhiteSpace(endDate)) return; + DateTime.TryParse(endDate, out _endDate).ShouldBeTrue(); + } + + [When(@"I get the chat sessions")] + public async Task WhenIGetTheChatSessions() + { + if (_exists) + { + var actor = ActorEntity.Create(_id, userInfo.OwnerId, Guid.NewGuid(), "John", "Doe", "jdoe@goodtocode.com"); + context.Actors.Add(actor); + await context.SaveChangesAsync(CancellationToken.None); + var chatSession = ChatSessionEntity.Create(_id, actor.Id, "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetMyChatSessionsQuery() + { + StartDate = _startDate == default ? null : _startDate, + EndDate = _endDate == default ? null : _endDate, + UserInfo = userInfo + }; + + var validator = new GetMyChatSessionsQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetAuthorChatSessionsByOwnerIdQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"The response has a collection of chat sessions")] + public void ThenTheResponseHasACollectionOfChatSessions() + { + _response?.Count.ShouldBe(_withinDateRangeExists == false ? 0 : _response.Count); + } + + [Then(@"Each chat session has a Key")] + public void ThenEachChatSessionHasAKey() + { + _response?.FirstOrDefault(x => x.Id == default).ShouldBeNull(); + } + + [Then(@"Each chat session has a Date greater than start date")] + public void ThenEachChatSessionHasADateGreaterThanStartDate() + { + if (_withinDateRangeExists) + _response?.FirstOrDefault(x => _startDate == default || x.Timestamp > _startDate).ShouldNotBeNull(); + } + + [Then(@"Each chat session has a Date less than end date")] + public void ThenEachChatSessionHasADateLessThanEndDate() + { + if (_withinDateRangeExists) + _response?.FirstOrDefault(x => _endDate == default || x.Timestamp < _endDate).ShouldNotBeNull(); + } +} diff --git a/src/Tests.Specs.Integration/Actor/GetActorQuery.feature b/src/Tests.Specs.Integration/Actor/GetActorQuery.feature new file mode 100644 index 0000000..27db4dc --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorQuery.feature @@ -0,0 +1,20 @@ +@getAuthorQuery +Feature: Get Actor Query +As a actor owner +When I select an existing Actor +I can see the actor detail + +Scenario: Get Actor + Given I have a definition "" + And I have a Actor id "" + And the Actor exists "" + When I get the Actor + Then The response is "" + And If the response has validation issues I see the "" in the response + And If the response is successful the response has a Id + +Examples: + | def | response | responseErrors | id | exists | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | + | bad request: empty id | BadRequest | ActorId | 00000000-0000-0000-0000-000000000000 | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Actor/GetActorQuery.feature.cs b/src/Tests.Specs.Integration/Actor/GetActorQuery.feature.cs new file mode 100644 index 0000000..a3d71ec --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorQuery.feature.cs @@ -0,0 +1,182 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Actor +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetActorQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getAuthorQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Actor", "Get Actor Query", "As a actor owner\r\nWhen I select an existing Actor\r\nI can see the actor detail", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetActorQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Actor/GetActorQuery.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get Actor")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get Actor")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Actor Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getAuthorQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="GetActor(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="GetActor(not found,NotFound,,048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty id", "BadRequest", "ActorId", "00000000-0000-0000-0000-000000000000", "false", "2", null, DisplayName="GetActor(bad request: empty id,BadRequest,ActorId,00000000-0000-0000-0000-0000000" + + "00000,false,2)")] + public async global::System.Threading.Tasks.Task GetActor(string def, string response, string responseErrors, string id, string exists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("exists", exists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get Actor", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a Actor id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("the Actor exists \"{0}\"", exists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I get the Actor", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync("If the response is successful the response has a Id", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Actor/GetActorQueryStepDefinitions.cs b/src/Tests.Specs.Integration/Actor/GetActorQueryStepDefinitions.cs new file mode 100644 index 0000000..673b248 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/GetActorQueryStepDefinitions.cs @@ -0,0 +1,83 @@ +using Goodtocode.AgentFramework.Core.Application.Actor; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Specs.Integration.Actor; + +[Binding] +[Scope(Tag = "getAuthorQuery")] +public class GetActorQueryStepDefinitions : TestBase +{ + private Guid _id; + private bool _exists; + private ActorDto? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"I have a Actor id ""([^""]*)""")] + public void GivenIHaveAAuthorId(string authorId) + { + if (string.IsNullOrWhiteSpace(authorId)) return; + Guid.TryParse(authorId, out _id).ShouldBeTrue(); + } + + [Given(@"the Actor exists ""([^""]*)""")] + public void GivenITheAuthorExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I get the Actor")] + public async Task WhenIGetAAuthor() + { + if (_exists) + { + var actor = ActorEntity.Create(_id, Guid.NewGuid(), Guid.NewGuid(), "John", "Doe", "jdoe@goodtocode.com"); + context.Actors.Add(actor); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetActorQuery() + { + ActorId = _id + }; + + var validator = new GetActorQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetAuthorQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"If the response is successful the response has a Id")] + public void ThenIfTheResponseIsSuccessfulTheResponseHasAKey() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Id.ShouldNotBeEmpty(); + } +} diff --git a/src/Tests.Specs.Integration/Actor/SaveActorCommand.feature b/src/Tests.Specs.Integration/Actor/SaveActorCommand.feature new file mode 100644 index 0000000..40f55b4 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/SaveActorCommand.feature @@ -0,0 +1,21 @@ +@saveAuthorCommand +Feature: Save Actor Command +As a owner +When I want to save a new Actor +Then I should see the Actor created with the initial response + +Scenario: Save Actor + Given I have a def "" + And I have a name "" + And I have a Actor id "" + And I have a External id "" + And I have a Email "" + And The Actor exists "" + When I create a actor + Then I see the Actor created with the initial response "" + And if the response has validation issues I see the "" in the response + +Examples: + | def | response | responseErrors | id | ownerId | exists | name | email | + | success | Success | | 00000000-0000-0000-0000-000000000000 | 938d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | John Doe | jdoe@goodtocode.com | + | already exists | Error | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | 938d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | Jane Doe | jane@goodtocode.com | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Actor/SaveActorCommand.feature.cs b/src/Tests.Specs.Integration/Actor/SaveActorCommand.feature.cs new file mode 100644 index 0000000..d0cf42d --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/SaveActorCommand.feature.cs @@ -0,0 +1,192 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Actor +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class SaveActorCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "saveAuthorCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Actor", "Save Actor Command", "As a owner\r\nWhen I want to save a new Actor\r\nThen I should see the Actor created " + + "with the initial response", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "SaveActorCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Actor/SaveActorCommand.feature.ndjson", 4); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Save Actor")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Save Actor")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Save Actor Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("saveAuthorCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "00000000-0000-0000-0000-000000000000", "938d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "John Doe", "jdoe@goodtocode.com", "0", null, DisplayName="SaveActor(success,Success,,00000000-0000-0000-0000-000000000000,938d8e7f-f18f-4a8" + + "e-8b3c-3b6a6889fed9,false,John Doe,jdoe@goodtocode.com,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("already exists", "Error", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "938d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "Jane Doe", "jane@goodtocode.com", "1", null, DisplayName="SaveActor(already exists,Error,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,938d8e7f-f18" + + "f-4a8e-8b3c-3b6a6889fed9,true,Jane Doe,jane@goodtocode.com,1)")] + public async global::System.Threading.Tasks.Task SaveActor(string def, string response, string responseErrors, string id, string ownerId, string exists, string name, string email, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("ownerId", ownerId); + argumentsOfScenario.Add("exists", exists); + argumentsOfScenario.Add("name", name); + argumentsOfScenario.Add("email", email); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Save Actor", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a name \"{0}\"", name), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a Actor id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a External id \"{0}\"", ownerId), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("I have a Email \"{0}\"", email), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("The Actor exists \"{0}\"", exists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.WhenAsync("I create a actor", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 15 + await testRunner.ThenAsync(string.Format("I see the Actor created with the initial response \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 16 + await testRunner.AndAsync(string.Format("if the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Actor/SaveActorCommandStepDefinitions.cs b/src/Tests.Specs.Integration/Actor/SaveActorCommandStepDefinitions.cs new file mode 100644 index 0000000..f814a14 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/SaveActorCommandStepDefinitions.cs @@ -0,0 +1,104 @@ +using Goodtocode.AgentFramework.Core.Application.Actor; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Specs.Integration.Actor; + +[Binding] +[Scope(Tag = "saveAuthorCommand")] +public class SaveActorCommandStepDefinitions : TestBase +{ + private string _name = string.Empty; + private Guid _id; + private bool _exists; + private Guid _ownerId = Guid.NewGuid(); + private readonly Guid _tenantId = Guid.NewGuid(); + private string _email = string.Empty; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a name ""([^""]*)""")] + public void GivenIHaveAName(string name) + { + _name = name; + } + + [Given("I have a Email {string}")] + public void GivenIHaveAEmail(string email) + { + _email = email; + } + + [Given(@"I have a Actor id ""([^""]*)""")] + public void GivenIHaveAAuthorId(string id) + { + _id = Guid.Parse(id); + } + + [Given("I have a External id {string}")] + public void GivenIHaveAExternalId(string ownerId) + { + _ownerId = Guid.Parse(ownerId); + } + + [Given(@"The Actor exists ""([^""]*)""")] + public void GivenTheAuthorExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I create a actor")] + public async Task WhenICreateAAuthor() + { + if (_exists) + { + var actor = ActorEntity.Create(_id, _ownerId, _tenantId, "John", "Doe", "jdoe@goodtocode.com"); + context.Actors.Add(actor); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new SaveMyActorCommand() + { + TenantId = _tenantId, + FirstName = _name.Split(" ").FirstOrDefault(), + LastName = _name.Split(" ").LastOrDefault(), + Email = _email, + UserInfo = userInfo + }; + + var validator = new SaveMyActorCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + { + try + { + var handler = new SaveAuthorCommandHandler(context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"I see the Actor created with the initial response ""([^""]*)""")] + public void ThenISeeTheAuthorCreatedWithTheInitialResponse(string response) + { + HandleHasResponseType(response); + } + + [Then(@"if the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + +} \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Actor/UpdateActorCommand.feature b/src/Tests.Specs.Integration/Actor/UpdateActorCommand.feature new file mode 100644 index 0000000..5dddabd --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/UpdateActorCommand.feature @@ -0,0 +1,18 @@ +@updateAuthorCommand +Feature: Update Actor Command +As a Actor owner +When I edit a Actor +I am able to change or add to the Actor + +Scenario: Update Actor + Given I have a def "" + And I have a Actor id "" + And the Actor exists "" + When I update the Actor + Then The response is "" + And If the response has validation issues I see the "" in the response + +Examples: + | def | response | responseErrors | id | exists | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | diff --git a/src/Tests.Specs.Integration/Actor/UpdateActorCommand.feature.cs b/src/Tests.Specs.Integration/Actor/UpdateActorCommand.feature.cs new file mode 100644 index 0000000..678aeee --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/UpdateActorCommand.feature.cs @@ -0,0 +1,177 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Actor +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class UpdateActorCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "updateAuthorCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Actor", "Update Actor Command", "As a Actor owner\r\nWhen I edit a Actor\r\nI am able to change or add to the Actor", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "UpdateActorCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Actor/UpdateActorCommand.feature.ndjson", 4); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Update Actor")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Update Actor")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Update Actor Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("updateAuthorCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="UpdateActor(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="UpdateActor(not found,NotFound,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false,1)")] + public async global::System.Threading.Tasks.Task UpdateActor(string def, string response, string responseErrors, string id, string exists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("exists", exists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Update Actor", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a Actor id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("the Actor exists \"{0}\"", exists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I update the Actor", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Actor/UpdateActorCommandStepDefinitions.cs b/src/Tests.Specs.Integration/Actor/UpdateActorCommandStepDefinitions.cs new file mode 100644 index 0000000..8faeaa2 --- /dev/null +++ b/src/Tests.Specs.Integration/Actor/UpdateActorCommandStepDefinitions.cs @@ -0,0 +1,77 @@ +using Goodtocode.AgentFramework.Core.Application.Actor; +using Goodtocode.AgentFramework.Core.Domain.Actor; + +namespace Goodtocode.AgentFramework.Specs.Integration.Actor +{ + [Binding] + [Scope(Tag = "updateAuthorCommand")] + public class UpdateActorCommandStepDefinitions : TestBase + { + private bool _exists; + private Guid _id; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a Actor id ""([^""]*)""")] + public void GivenIHaveAAuthorId(string id) + { + Guid.TryParse(id, out _id).ShouldBeTrue(); + } + + [Given(@"the Actor exists ""([^""]*)""")] + public void GivenTheAuthorExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I update the Actor")] + public async Task WhenIUpdateTheAuthor() + { + if (_exists) + { + var actor = ActorEntity.Create(_id, _id, Guid.NewGuid(), "John", "Doe", "jdoe@goodtocode.com"); + context.Actors.Add(actor); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new UpdateActorCommand() + { + OwnerId = _id, + Name = "Joe Doe" + }; + + var validator = new UpdateActorCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + try + { + var handler = new UpdateAuthorCommandHandler(context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + } +} diff --git a/src/Tests.Specs.Integration/Audio/CreateTextToAudioCommand.feature b/src/Tests.Specs.Integration/Audio/CreateTextToAudioCommand.feature new file mode 100644 index 0000000..322c9e0 --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/CreateTextToAudioCommand.feature @@ -0,0 +1,20 @@ +@createTextToAudioCommand +Feature: Create Text To Audio Command +As a actor +When I start a new text audio and enter an initial prompt +Then I should see the text audio created with the initial response + +Scenario: Create Text Audio + Given I have a def "" + And I have a initial prompt "" + And I have a text audio id "" + And The text audio exists "" + When I create a text audio with the prompt + Then I see the text audio created with the initial response "" + And if the response has validation issues I see the "" in the response + +Examples: + | def | response | responseErrors | id | textAudioExists | prompt | + | success | Success | | 00000000-0000-0000-0000-000000000000 | false | Hello, I am a voice generated from text. | + | bad request: empty propmt | BadRequest | Prompt | 00000000-0000-0000-0000-000000000000 | false | | + | already exists | Error | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | Hello, I am a voice generated from text. | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Audio/CreateTextToAudioCommand.feature.cs b/src/Tests.Specs.Integration/Audio/CreateTextToAudioCommand.feature.cs new file mode 100644 index 0000000..4818bec --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/CreateTextToAudioCommand.feature.cs @@ -0,0 +1,186 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Audio +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class CreateTextToAudioCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "createTextToAudioCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Audio", "Create Text To Audio Command", "As a actor\r\nWhen I start a new text audio and enter an initial prompt\r\nThen I sho" + + "uld see the text audio created with the initial response", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "CreateTextToAudioCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Audio/CreateTextToAudioCommand.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Create Text Audio")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Create Text Audio")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Create Text To Audio Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("createTextToAudioCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "00000000-0000-0000-0000-000000000000", "false", "Hello, I am a voice generated from text.", "0", null, DisplayName="CreateTextAudio(success,Success,,00000000-0000-0000-0000-000000000000,false,Hello" + + ", I am a voice generated from text.,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty propmt", "BadRequest", "Prompt", "00000000-0000-0000-0000-000000000000", "false", "", "1", null, DisplayName="CreateTextAudio(bad request: empty propmt,BadRequest,Prompt,00000000-0000-0000-00" + + "00-000000000000,false,,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("already exists", "Error", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "Hello, I am a voice generated from text.", "2", null, DisplayName="CreateTextAudio(already exists,Error,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,H" + + "ello, I am a voice generated from text.,2)")] + public async global::System.Threading.Tasks.Task CreateTextAudio(string def, string response, string responseErrors, string id, string textAudioExists, string prompt, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("textAudioExists", textAudioExists); + argumentsOfScenario.Add("prompt", prompt); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Create Text Audio", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a initial prompt \"{0}\"", prompt), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a text audio id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("The text audio exists \"{0}\"", textAudioExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.WhenAsync("I create a text audio with the prompt", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 13 + await testRunner.ThenAsync(string.Format("I see the text audio created with the initial response \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("if the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Audio/CreateTextToAudioCommandStepDefinitions.cs b/src/Tests.Specs.Integration/Audio/CreateTextToAudioCommandStepDefinitions.cs new file mode 100644 index 0000000..31adbcd --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/CreateTextToAudioCommandStepDefinitions.cs @@ -0,0 +1,93 @@ +using Goodtocode.AgentFramework.Core.Application.Audio; +using Goodtocode.AgentFramework.Core.Domain.Audio; + +namespace Goodtocode.AgentFramework.Specs.Integration.Audio; + +[Binding] +[Scope(Tag = "createTextToAudioCommand")] +public class CreateTextToAudioCommandStepDefinitions : TestBase +{ + private string _prompt = string.Empty; + private Guid _id; + private bool _exists; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a initial prompt ""([^""]*)""")] + public void GivenIHaveAInitialprompt(string prompt) + { + _prompt = prompt; + } + + [Given(@"I have a text audio id ""([^""]*)""")] + public void GivenIHaveATextAudioKey(string id) + { + _id = Guid.Parse(id); + } + + [Given(@"The text audio exists ""([^""]*)""")] + public void GivenThetextAudioExists(string exists) + { + _exists = bool.Parse(exists); + } + + [When(@"I create a text audio with the prompt")] + public async Task WhenICreateATextAudioWithTheprompt() + { + // Setup the database if want to test existing records + if (_exists) + { + var textAudio = TextAudioEntity.Create(_id, Guid.NewGuid(), "Audio of a simple geometric design consisting of two yellow squares and one blue square. " + + "The blue square is placed at a 45-degree angle, positioned centrally below the two yellow squares, creating a symmetrical arrangement. " + + "Each square is connected by what appears to be black lines or sticks, suggesting they may represent nodes or elements in a network or structure. " + + "The background is white, which contrasts with the bright colors of the squares.", + new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]) + ); + context.TextAudio.Add(textAudio); + await context.SaveChangesAsync(CancellationToken.None); + } + + // Test command + var request = new CreateTextToAudioCommand() + { + Id = _id, + ActorId = Guid.NewGuid(), + Prompt = _prompt + }; + + var validator = new CreateTextToAudioCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + { + try + { + var handler = new CreateTextToAudioCommandHandler(kernel, context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"I see the text audio created with the initial response ""([^""]*)""")] + public void ThenISeeTheTextAudioCreatedWithTheInitialResponse(string response) + { + HandleHasResponseType(response); + } + + [Then(@"if the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } +} diff --git a/src/Tests.Specs.Integration/Audio/DeleteTextAudioCommand.feature b/src/Tests.Specs.Integration/Audio/DeleteTextAudioCommand.feature new file mode 100644 index 0000000..0537201 --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/DeleteTextAudioCommand.feature @@ -0,0 +1,19 @@ +@deleteTextAudioCommand +Feature: Delete Text Audio Command +As a text audio owner +When I select a text audio +I can delete the text audio + +Scenario: Delete Text Audio + Given I have a def "" + And I have a text audio id"" + And The text audio exists "" + When I delete the text audio + Then The response is "" + And If the response has validation issues I see the "" in the response + +Examples: + | def | response | responseErrors | id | exists | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | + | bad request: empty id | BadRequest | Id | 00000000-0000-0000-0000-000000000000 | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Audio/DeleteTextAudioCommand.feature.cs b/src/Tests.Specs.Integration/Audio/DeleteTextAudioCommand.feature.cs new file mode 100644 index 0000000..bcbab4e --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/DeleteTextAudioCommand.feature.cs @@ -0,0 +1,180 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Audio +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class DeleteTextAudioCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "deleteTextAudioCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Audio", "Delete Text Audio Command", "As a text audio owner\r\nWhen I select a text audio\r\nI can delete the text audio", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "DeleteTextAudioCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Audio/DeleteTextAudioCommand.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Delete Text Audio")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Delete Text Audio")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Delete Text Audio Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("deleteTextAudioCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="DeleteTextAudio(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="DeleteTextAudio(not found,NotFound,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false,1)" + + "")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty id", "BadRequest", "Id", "00000000-0000-0000-0000-000000000000", "false", "2", null, DisplayName="DeleteTextAudio(bad request: empty id,BadRequest,Id,00000000-0000-0000-0000-00000" + + "0000000,false,2)")] + public async global::System.Threading.Tasks.Task DeleteTextAudio(string def, string response, string responseErrors, string id, string exists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("exists", exists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Delete Text Audio", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a text audio id\"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("The text audio exists \"{0}\"", exists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I delete the text audio", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Audio/DeleteTextAudioCommandStepDefinitions.cs b/src/Tests.Specs.Integration/Audio/DeleteTextAudioCommandStepDefinitions.cs new file mode 100644 index 0000000..a9ff1be --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/DeleteTextAudioCommandStepDefinitions.cs @@ -0,0 +1,80 @@ +using Goodtocode.AgentFramework.Core.Application.Audio; +using Goodtocode.AgentFramework.Core.Domain.Audio; + +namespace Goodtocode.AgentFramework.Specs.Integration.Audio +{ + [Binding] + [Scope(Tag = "deleteTextAudioCommand")] + public class DeleteTextAudioCommandStepDefinitions : TestBase + { + private Guid _id; + private bool _exists; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a text audio id""([^""]*)""")] + public void GivenIHaveATextAudioKey(string id) + { + Guid.TryParse(id, out _id).ShouldBeTrue(); + } + + [Given(@"The text audio exists ""([^""]*)""")] + public void GivenThetextAudioExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I delete the text audio")] + public async Task WhenIDeleteTheTextAudio() + { + var request = new DeleteTextAudioCommand() + { + Id = _id + }; + + if (_exists) + { + var textAudio = TextAudioEntity.Create(_id, Guid.NewGuid(), "Audio of a simple geometric design consisting of two yellow squares and one blue square. " + + "The blue square is placed at a 45-degree angle, positioned centrally below the two yellow squares, creating a symmetrical arrangement. " + + "Each square is connected by what appears to be black lines or sticks, suggesting they may represent nodes or elements in a network or structure. " + + "The background is white, which contrasts with the bright colors of the squares.", + new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04])); + context.TextAudio.Add(textAudio); + await context.SaveChangesAsync(CancellationToken.None); + } + + var validator = new DeleteTextAudioCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + try + { + var handler = new DeleteTextAudioCommandHandler(context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + } +} diff --git a/src/Tests.Specs.Integration/Audio/GetTextAudioQuery.feature b/src/Tests.Specs.Integration/Audio/GetTextAudioQuery.feature new file mode 100644 index 0000000..46c21f7 --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/GetTextAudioQuery.feature @@ -0,0 +1,20 @@ +@getTextAudioQuery +Feature: Get Text Audio Query +As a actor +When I select an existing text audio +I can see the text audio responses + +Scenario: Get text audio + Given I have a definition "" + And I have a text audio id "" + And I the text audio exists "" + When I get a text audio + Then The response is "" + And If the response has validation issues I see the "" in the response + And If the response is successful the response has a Key + +Examples: + | def | response | responseErrors | id | textPromptExists | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | + | bad request: empty id | BadRequest | Id | 00000000-0000-0000-0000-000000000000 | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Audio/GetTextAudioQuery.feature.cs b/src/Tests.Specs.Integration/Audio/GetTextAudioQuery.feature.cs new file mode 100644 index 0000000..d518553 --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/GetTextAudioQuery.feature.cs @@ -0,0 +1,183 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Audio +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetTextAudioQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getTextAudioQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Audio", "Get Text Audio Query", "As a actor\r\nWhen I select an existing text audio\r\nI can see the text audio respon" + + "ses", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetTextAudioQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Audio/GetTextAudioQuery.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get text audio")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get text audio")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Text Audio Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getTextAudioQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="GetTextAudio(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="GetTextAudio(not found,NotFound,,048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty id", "BadRequest", "Id", "00000000-0000-0000-0000-000000000000", "false", "2", null, DisplayName="GetTextAudio(bad request: empty id,BadRequest,Id,00000000-0000-0000-0000-00000000" + + "0000,false,2)")] + public async global::System.Threading.Tasks.Task GetTextAudio(string def, string response, string responseErrors, string id, string textPromptExists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("textPromptExists", textPromptExists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get text audio", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a text audio id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I the text audio exists \"{0}\"", textPromptExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I get a text audio", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync("If the response is successful the response has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Audio/GetTextAudioQueryStepDefinitions.cs b/src/Tests.Specs.Integration/Audio/GetTextAudioQueryStepDefinitions.cs new file mode 100644 index 0000000..402d8ea --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/GetTextAudioQueryStepDefinitions.cs @@ -0,0 +1,88 @@ +using Goodtocode.AgentFramework.Core.Application.Audio; +using Goodtocode.AgentFramework.Core.Domain.Audio; + +namespace Goodtocode.AgentFramework.Specs.Integration.Audio; + +[Binding] +[Scope(Tag = "getTextAudioQuery")] +public class GetTextAudioQueryStepDefinitions : TestBase +{ + private Guid _id; + private bool _exists; + private TextAudioDto? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"I have a text audio id ""([^""]*)""")] + public void GivenIHaveATextAudioKey(string textPromptKey) + { + if (string.IsNullOrWhiteSpace(textPromptKey)) return; + Guid.TryParse(textPromptKey, out _id).ShouldBeTrue(); + } + + [Given(@"I the text audio exists ""([^""]*)""")] + public void GivenIThetextAudioExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I get a text audio")] + public async Task WhenIGetATextAudio() + { + if (_exists) + { + var textAudio = TextAudioEntity.Create(_id, Guid.NewGuid(), + "Audio of a simple geometric design consisting of two yellow squares and one blue square. " + + "The blue square is placed at a 45-degree angle, positioned centrally below the two yellow squares, creating a symmetrical arrangement. " + + "Each square is connected by what appears to be black lines or sticks, suggesting they may represent nodes or elements in a network or structure. " + + "The background is white, which contrasts with the bright colors of the squares.", + new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04])); + context.TextAudio.Add(textAudio); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetTextAudioQuery() + { + Id = _id + }; + + var validator = new GetTextAudioQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetTextAudioQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"If the response is successful the response has a Key")] + public void ThenIfTheResponseIsSuccessfulTheResponseHasAKey() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Id.ShouldNotBeEmpty(); + } +} diff --git a/src/Tests.Specs.Integration/Audio/GetTextAudiosPaginatedQuery.feature b/src/Tests.Specs.Integration/Audio/GetTextAudiosPaginatedQuery.feature new file mode 100644 index 0000000..fc2d10b --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/GetTextAudiosPaginatedQuery.feature @@ -0,0 +1,33 @@ +@getTextAudioPaginatedQuery +Feature: Get Text Audio Paginated Query +As an owner of text audio +When I get text audio all or by date range +I can get a paginated collection of text audio + +Scenario: Get text audio paginated + Given I have a definition "" + And Text Audio exist "" + And I have a start date "" + And I have a end date "" + And text audio within the date range exists "" + And I have a page number "" + And I have a page size "" + When I get the text audio paginated + Then The response is "" + And If the response has validation issues I see the "" in the response + And The response has a collection of text audio + And Each text audio has a Key + And Each text audio has a Date greater than start date + And Each text audio has a Date less than end date + And The response has a Page Number + And The response has a Total Pages + And The response has a Total Count + + +Examples: + | def | response | responseErrors | startDate | endDate | exist | textPromptsResultExists | pageNumber | pageSize | + | success no date range | Success | | | | true | true | 1 | 10 | + | success with date range | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | true | 1 | 10 | + | success empty results | Success | | | | false | false | 1 | 10 | + | bad request page number zero | BadRequest | PageNumber | | | false | false | 0 | 10 | + | bad request page size zero | BadRequest | PageSize | | | false | false | 1 | 0 | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Audio/GetTextAudiosPaginatedQuery.feature.cs b/src/Tests.Specs.Integration/Audio/GetTextAudiosPaginatedQuery.feature.cs new file mode 100644 index 0000000..0cc419a --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/GetTextAudiosPaginatedQuery.feature.cs @@ -0,0 +1,221 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Audio +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetTextAudioPaginatedQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getTextAudioPaginatedQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Audio", "Get Text Audio Paginated Query", "As an owner of text audio\r\nWhen I get text audio all or by date range\r\nI can get " + + "a paginated collection of text audio", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetTextAudiosPaginatedQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Audio/GetTextAudiosPaginatedQuery.feature.ndjson", 7); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get text audio paginated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get text audio paginated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Text Audio Paginated Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getTextAudioPaginatedQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success no date range", "Success", "", "", "", "true", "true", "1", "10", "0", null, DisplayName="GetTextAudioPaginated(success no date range,Success,,,,true,true,1,10,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", "10", "1", null, DisplayName="GetTextAudioPaginated(success with date range,Success,,2024-06-01T11:21:00Z,2034-" + + "06-03T11:21:00Z,true,true,1,10,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success empty results", "Success", "", "", "", "false", "false", "1", "10", "2", null, DisplayName="GetTextAudioPaginated(success empty results,Success,,,,false,false,1,10,2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request page number zero", "BadRequest", "PageNumber", "", "", "false", "false", "0", "10", "3", null, DisplayName="GetTextAudioPaginated(bad request page number zero,BadRequest,PageNumber,,,false," + + "false,0,10,3)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request page size zero", "BadRequest", "PageSize", "", "", "false", "false", "1", "0", "4", null, DisplayName="GetTextAudioPaginated(bad request page size zero,BadRequest,PageSize,,,false,fals" + + "e,1,0,4)")] + public async global::System.Threading.Tasks.Task GetTextAudioPaginated(string def, string response, string responseErrors, string startDate, string endDate, string exist, string textPromptsResultExists, string pageNumber, string pageSize, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("startDate", startDate); + argumentsOfScenario.Add("endDate", endDate); + argumentsOfScenario.Add("exist", exist); + argumentsOfScenario.Add("textPromptsResultExists", textPromptsResultExists); + argumentsOfScenario.Add("pageNumber", pageNumber); + argumentsOfScenario.Add("pageSize", pageSize); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get text audio paginated", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("Text Audio exist \"{0}\"", exist), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a start date \"{0}\"", startDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a end date \"{0}\"", endDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("text audio within the date range exists \"{0}\"", textPromptsResultExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("I have a page number \"{0}\"", pageNumber), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("I have a page size \"{0}\"", pageSize), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 15 + await testRunner.WhenAsync("I get the text audio paginated", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 16 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 17 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 18 + await testRunner.AndAsync("The response has a collection of text audio", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 19 + await testRunner.AndAsync("Each text audio has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 20 + await testRunner.AndAsync("Each text audio has a Date greater than start date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 21 + await testRunner.AndAsync("Each text audio has a Date less than end date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 22 + await testRunner.AndAsync("The response has a Page Number", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 23 + await testRunner.AndAsync("The response has a Total Pages", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 24 + await testRunner.AndAsync("The response has a Total Count", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Audio/GetTextAudiosPaginatedQueryStepDefinitions.cs b/src/Tests.Specs.Integration/Audio/GetTextAudiosPaginatedQueryStepDefinitions.cs new file mode 100644 index 0000000..23e8f9e --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/GetTextAudiosPaginatedQueryStepDefinitions.cs @@ -0,0 +1,171 @@ +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Core.Application.Audio; +using Goodtocode.AgentFramework.Core.Domain.Audio; + +namespace Goodtocode.AgentFramework.Specs.Integration.Audio +{ + [Binding] + [Scope(Tag = "getTextAudioPaginatedQuery")] + public class GetTextAudioPaginatedQueryStepDefinitions : TestBase + { + private bool _exists; + private DateTime _startDate; + private DateTime _endDate; + private bool _withinDateRangeExists; + private int _pageNumber; + private int _pageSize; + private PaginatedList? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"Text Audio exist ""([^""]*)""")] + public void GivenTextAudioExist(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"I have a start date ""([^""]*)""")] + public void GivenIHaveAStartDate(string startDate) + { + if (string.IsNullOrWhiteSpace(startDate)) return; + DateTime.TryParse(startDate, out _startDate).ShouldBeTrue(); + _startDate = _withinDateRangeExists ? _startDate : _startDate.AddMinutes(1); //Handle for desired not-found scenarios + } + + [Given(@"I have a end date ""([^""]*)""")] + public void GivenIHaveAEndDate(string endDate) + { + if (string.IsNullOrWhiteSpace(endDate)) return; + DateTime.TryParse(endDate, out _endDate).ShouldBeTrue(); + } + + [Given(@"text audio within the date range exists ""([^""]*)""")] + public void GivenTextAudioWithinTheDateRangeExists(string withinDateRangeExists) + { + bool.TryParse(withinDateRangeExists, out _withinDateRangeExists).ShouldBeTrue(); + } + + [Given(@"I have a page number ""([^""]*)""")] + public void GivenIHaveAPageNumber(string pageNumber) + { + int.TryParse(pageNumber, out _pageNumber).ShouldBeTrue(); + } + + [Given(@"I have a page size ""([^""]*)""")] + public void GivenIHaveAPageSize(string pageSize) + { + int.TryParse(pageSize, out _pageSize).ShouldBeTrue(); ; + } + + [When(@"I get the text audio paginated")] + public async Task WhenIGetTheTextAudioPaginated() + { + if (_exists) + { + for (int i = 0; i < 2; i++) + { + var textAudio = TextAudioEntity.Create(Guid.NewGuid(), + Guid.NewGuid(), + "Audio of a simple geometric design consisting of two yellow squares and one blue square. " + + "The blue square is placed at a 45-degree angle, positioned centrally below the two yellow squares, creating a symmetrical arrangement. " + + "Each square is connected by what appears to be black lines or sticks, suggesting they may represent nodes or elements in a network or structure. " + + "The background is white, which contrasts with the bright colors of the squares.", + new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]) + ); + context.TextAudio.Add(textAudio); + } + ; + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetTextAudioPaginatedQuery() + { + PageNumber = _pageNumber, + PageSize = _pageSize, + StartDate = _startDate == default ? null : _startDate, + EndDate = _endDate == default ? null : _endDate + }; + + var validator = new GetTextAudioPaginatedQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetTextAudioPaginatedQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"The response has a collection of text audio")] + public void ThenTheResponseHasACollectionOfTextAudio() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalCount.ShouldBe(_withinDateRangeExists == false ? 0 : _response.TotalCount); + } + + [Then(@"Each text audio has a Key")] + public void ThenEachTextAudioHasAKey() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Items.FirstOrDefault(x => x.Id == default).ShouldBeNull(); + } + + [Then(@"Each text audio has a Date greater than start date")] + public void ThenEachTextAudioHasADateGreaterThanStartDate() + { + if (responseType == CommandResponseType.Successful && _withinDateRangeExists) + _response?.Items.FirstOrDefault(x => _startDate == default || x.Timestamp > _startDate).ShouldNotBeNull(); + } + + [Then(@"Each text audio has a Date less than end date")] + public void ThenEachTextAudioHasADateLessThanEndDate() + { + if (responseType == CommandResponseType.Successful && _withinDateRangeExists) + _response?.Items.FirstOrDefault(x => _endDate == default || x.Timestamp < _endDate).ShouldNotBeNull(); + } + + [Then(@"The response has a Page Number")] + public void ThenTheResponseHasAPageNumber() + { + if (responseType != CommandResponseType.Successful) return; + _response?.PageNumber.Should(); + } + + [Then(@"The response has a Total Pages")] + public void ThenTheResponseHasATotalPages() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalPages.Should(); + } + + [Then(@"The response has a Total Count")] + public void ThenTheResponseHasATotalCount() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalCount.Should(); + } + } +} diff --git a/src/Tests.Specs.Integration/Audio/GetTextAudiosQuery.feature b/src/Tests.Specs.Integration/Audio/GetTextAudiosQuery.feature new file mode 100644 index 0000000..a078952 --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/GetTextAudiosQuery.feature @@ -0,0 +1,26 @@ +@getTextAudiosQuery +Feature: Get Text Audios Query +As a session owner +When I query text audio optionally by date range +I get all sessions that fit the date range + +Scenario: Get text audios + Given I have a definition "" + And Text Audio exist "" + And text audio within the date range exists "" + And I have a start date "" + And I have a end date "" + When I get the text audio + Then The response is "" + And If the response has validation issues I see the "" in the response + And The response has a collection of text audio + And Each text audio has a Key + And Each text audio has a Date greater than start date + And Each text audio has a Date less than end date + +Examples: + | def | response | responseErrors | startDate | endDate | exist | textPromptsResultExists | + | success no date range | Success | | | | true | true | + | success with date range | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | true | + | success filtered results | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | false | + | success empty results | Success | | | | false | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Audio/GetTextAudiosQuery.feature.cs b/src/Tests.Specs.Integration/Audio/GetTextAudiosQuery.feature.cs new file mode 100644 index 0000000..86e2735 --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/GetTextAudiosQuery.feature.cs @@ -0,0 +1,202 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Audio +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetTextAudiosQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getTextAudiosQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Audio", "Get Text Audios Query", "As a session owner\r\nWhen I query text audio optionally by date range\r\nI get all s" + + "essions that fit the date range", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetTextAudiosQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Audio/GetTextAudiosQuery.feature.ndjson", 6); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get text audios")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get text audios")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Text Audios Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getTextAudiosQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success no date range", "Success", "", "", "", "true", "true", "0", null, DisplayName="GetTextAudios(success no date range,Success,,,,true,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", null, DisplayName="GetTextAudios(success with date range,Success,,2024-06-01T11:21:00Z,2034-06-03T11" + + ":21:00Z,true,true,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success filtered results", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "false", "2", null, DisplayName="GetTextAudios(success filtered results,Success,,2024-06-01T11:21:00Z,2034-06-03T1" + + "1:21:00Z,true,false,2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success empty results", "Success", "", "", "", "false", "false", "3", null, DisplayName="GetTextAudios(success empty results,Success,,,,false,false,3)")] + public async global::System.Threading.Tasks.Task GetTextAudios(string def, string response, string responseErrors, string startDate, string endDate, string exist, string textPromptsResultExists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("startDate", startDate); + argumentsOfScenario.Add("endDate", endDate); + argumentsOfScenario.Add("exist", exist); + argumentsOfScenario.Add("textPromptsResultExists", textPromptsResultExists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get text audios", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("Text Audio exist \"{0}\"", exist), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("text audio within the date range exists \"{0}\"", textPromptsResultExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a start date \"{0}\"", startDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("I have a end date \"{0}\"", endDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.WhenAsync("I get the text audio", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 14 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 15 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 16 + await testRunner.AndAsync("The response has a collection of text audio", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 17 + await testRunner.AndAsync("Each text audio has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 18 + await testRunner.AndAsync("Each text audio has a Date greater than start date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 19 + await testRunner.AndAsync("Each text audio has a Date less than end date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Audio/GetTextAudiosQueryStepDefinitions.cs b/src/Tests.Specs.Integration/Audio/GetTextAudiosQueryStepDefinitions.cs new file mode 100644 index 0000000..0ddea80 --- /dev/null +++ b/src/Tests.Specs.Integration/Audio/GetTextAudiosQueryStepDefinitions.cs @@ -0,0 +1,130 @@ +using Goodtocode.AgentFramework.Core.Application.Audio; +using Goodtocode.AgentFramework.Core.Domain.Audio; + +namespace Goodtocode.AgentFramework.Specs.Integration.Audio; + +[Binding] +[Scope(Tag = "getTextAudiosQuery")] +public class GetTextAudiosQueryStepDefinitions : TestBase +{ + private bool _exists; + private bool _withinDateRangeExists; + private DateTime _endDate; + private DateTime _startDate; + private ICollection? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"Text Audio exist ""([^""]*)""")] + public void GivenTextAudioExist(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"text audio within the date range exists ""([^""]*)""")] + public void GivenTextAudioWithinTheDateRangeExists(string withinDateRangeExists) + { + bool.TryParse(withinDateRangeExists, out _withinDateRangeExists).ShouldBeTrue(); + } + + [Given(@"I have a start date ""([^""]*)""")] + public void GivenIHaveAStartDate(string startDate) + { + if (string.IsNullOrWhiteSpace(startDate)) return; + DateTime.TryParse(startDate, out _startDate).ShouldBeTrue(); + _startDate = DateTime.UtcNow.AddMinutes(_withinDateRangeExists ? -1 : 1); //Handle for desired not-found scenarios + } + + [Given(@"I have a end date ""([^""]*)""")] + public void GivenIHaveAEndDate(string endDate) + { + if (string.IsNullOrWhiteSpace(endDate)) return; + DateTime.TryParse(endDate, out _endDate).ShouldBeTrue(); + } + + [When(@"I get the text audio")] + public async Task WhenIGetTheTextAudio() + { + if (_exists) + { + for (int i = 0; i < 2; i++) + { + var textAudio = TextAudioEntity.Create(Guid.NewGuid(), + Guid.NewGuid(), + "Audio of a simple geometric design consisting of two yellow squares and one blue square. " + + "The blue square is placed at a 45-degree angle, positioned centrally below the two yellow squares, creating a symmetrical arrangement. " + + "Each square is connected by what appears to be black lines or sticks, suggesting they may represent nodes or elements in a network or structure. " + + "The background is white, which contrasts with the bright colors of the squares.", + new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]) + ); + context.TextAudio.Add(textAudio); + } + ; + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetTextAudiosQuery() + { + StartDate = _startDate == default ? null : _startDate, + EndDate = _endDate == default ? null : _endDate + }; + + var validator = new GetTextAudiosQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetTextAudiosQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"The response has a collection of text audio")] + public void ThenTheResponseHasACollectionOfTextAudio() + { + _response?.Count.ShouldBe(_withinDateRangeExists == false ? 0 : _response.Count); + } + + [Then(@"Each text audio has a Key")] + public void ThenEachTextAudioHasAKey() + { + _response?.FirstOrDefault(x => x.Id == default).ShouldBeNull(); + } + + [Then(@"Each text audio has a Date greater than start date")] + public void ThenEachTextAudioHasADateGreaterThanStartDate() + { + if (_withinDateRangeExists) + _response?.FirstOrDefault(x => _startDate == default || x.Timestamp > _startDate).ShouldNotBeNull(); + } + + [Then(@"Each text audio has a Date less than end date")] + public void ThenEachTextAudioHasADateLessThanEndDate() + { + if (_withinDateRangeExists) + _response?.FirstOrDefault(x => _endDate == default || x.Timestamp < _endDate).ShouldNotBeNull(); + } +} diff --git a/src/Tests.Specs.Integration/ChatCompletion/CreateChatMessageCommand.feature b/src/Tests.Specs.Integration/ChatCompletion/CreateChatMessageCommand.feature new file mode 100644 index 0000000..29acf8d --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/CreateChatMessageCommand.feature @@ -0,0 +1,23 @@ +@createChatMessageCommand +Feature: Create Chat Message Command +As a chat user +When I start a new Chat Message and enter an initial message +Then I should see the Chat Message created with the initial response + +Scenario: Create Chat Message + Given I have a def "" + And I have a initial message "" + And I have a Chat Message id "" + And The Chat Message exists "" + When I create a Chat Message with the message + Then I see the Chat Message created with the initial response "" + And if the response has validation issues I see the "" in the response + +Examples: + | def | response | responseErrors | id | ChatMessageExists | message | + | success | Success | | 00000000-0000-0000-0000-000000000000 | true | Hello, I am interested in an interactive Chat Message. | + | success actor plugin | Success | | 00000000-0000-0000-0000-000000000000 | true | Please call get_author that Returns the actor's name for the specified actor ID, or 'Actor not found' if no match exists | + | success session plugin | Success | | 00000000-0000-0000-0000-000000000000 | true | Please call list_sessions that Lists all sessions, optionally by date | + | success messages plugin | Success | | 00000000-0000-0000-0000-000000000000 | true | Please call list_messages that Lists all sessions, optionally by date | + | bad request: empty message | BadRequest | Message | 00000000-0000-0000-0000-000000000000 | false | | + | already exists | Error | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | Hello, I am interested in an interactive Chat Message. | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/ChatCompletion/CreateChatMessageCommand.feature.cs b/src/Tests.Specs.Integration/ChatCompletion/CreateChatMessageCommand.feature.cs new file mode 100644 index 0000000..c676936 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/CreateChatMessageCommand.feature.cs @@ -0,0 +1,196 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class CreateChatMessageCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "createChatMessageCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "ChatCompletion", "Create Chat Message Command", "As a chat user\r\nWhen I start a new Chat Message and enter an initial message\r\nThe" + + "n I should see the Chat Message created with the initial response", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "CreateChatMessageCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("ChatCompletion/CreateChatMessageCommand.feature.ndjson", 8); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Create Chat Message")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Create Chat Message")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Create Chat Message Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("createChatMessageCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "00000000-0000-0000-0000-000000000000", "true", "Hello, I am interested in an interactive Chat Message.", "0", null, DisplayName="CreateChatMessage(success,Success,,00000000-0000-0000-0000-000000000000,true,Hell" + + "o, I am interested in an interactive Chat Message.,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success actor plugin", "Success", "", "00000000-0000-0000-0000-000000000000", "true", "Please call get_author that Returns the actor\'s name for the specified actor ID, " + + "or \'Actor not found\' if no match exists", "1", null, DisplayName="CreateChatMessage(success actor plugin,Success,,00000000-0000-0000-0000-000000000" + + "000,true,Please call get_author that Returns the actor\'s name for the specified " + + "actor ID, or \'Actor not found\' if no match exists,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success session plugin", "Success", "", "00000000-0000-0000-0000-000000000000", "true", "Please call list_sessions that Lists all sessions, optionally by date", "2", null, DisplayName="CreateChatMessage(success session plugin,Success,,00000000-0000-0000-0000-0000000" + + "00000,true,Please call list_sessions that Lists all sessions, optionally by date" + + ",2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success messages plugin", "Success", "", "00000000-0000-0000-0000-000000000000", "true", "Please call list_messages that Lists all sessions, optionally by date", "3", null, DisplayName="CreateChatMessage(success messages plugin,Success,,00000000-0000-0000-0000-000000" + + "000000,true,Please call list_messages that Lists all sessions, optionally by dat" + + "e,3)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty message", "BadRequest", "Message", "00000000-0000-0000-0000-000000000000", "false", "", "4", null, DisplayName="CreateChatMessage(bad request: empty message,BadRequest,Message,00000000-0000-000" + + "0-0000-000000000000,false,,4)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("already exists", "Error", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "Hello, I am interested in an interactive Chat Message.", "5", null, DisplayName="CreateChatMessage(already exists,Error,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true" + + ",Hello, I am interested in an interactive Chat Message.,5)")] + public async global::System.Threading.Tasks.Task CreateChatMessage(string def, string response, string responseErrors, string id, string chatMessageExists, string message, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("ChatMessageExists", chatMessageExists); + argumentsOfScenario.Add("message", message); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Create Chat Message", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a initial message \"{0}\"", message), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a Chat Message id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("The Chat Message exists \"{0}\"", chatMessageExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.WhenAsync("I create a Chat Message with the message", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 13 + await testRunner.ThenAsync(string.Format("I see the Chat Message created with the initial response \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("if the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/ChatCompletion/CreateChatMessageCommandStepDefinitions.cs b/src/Tests.Specs.Integration/ChatCompletion/CreateChatMessageCommandStepDefinitions.cs new file mode 100644 index 0000000..4345f30 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/CreateChatMessageCommandStepDefinitions.cs @@ -0,0 +1,95 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Domain.Actor; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion; + +[Binding] +[Scope(Tag = "createChatMessageCommand")] +public class CreateChatMessageCommandStepDefinitions : TestBase +{ + private string _message = string.Empty; + private Guid _id; + private readonly Guid _chatSessionId = Guid.NewGuid(); + private bool _exists; + private readonly TestUserInfo _userInfo = new(); + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a initial message ""([^""]*)""")] + public void GivenIHaveAInitialMessage(string message) + { + _message = message; + } + + [Given(@"I have a Chat Message id ""([^""]*)""")] + public void GivenIHaveAChatMessageKey(string id) + { + _id = Guid.Parse(id); + } + + [Given(@"The Chat Message exists ""([^""]*)""")] + public void GivenTheChatMessageExists(string exists) + { + _exists = bool.Parse(exists); + } + + [When(@"I create a Chat Message with the message")] + public async Task WhenICreateAChatMessageWithTheMessage() + { + // Setup the database if want to test existing records + if (_exists) + { + var actor = ActorEntity.Create(_userInfo); + context.Actors.Add(actor); + await context.SaveChangesAsync(CancellationToken.None); + var chatSession = ChatSessionEntity.Create(_chatSessionId, actor.Id, "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + // Test command + var request = new CreateChatMessageCommand() + { + Id = _id, + ChatSessionId = _chatSessionId, + Message = _message, + UserInfo = _userInfo + }; + + var validator = new CreateChatMessageCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + { + try + { + var handler = new CreateChatMessageCommandHandler(kernel, context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"I see the Chat Message created with the initial response ""([^""]*)""")] + public void ThenISeeTheChatMessageCreatedWithTheInitialResponse(string response) + { + HandleHasResponseType(response); + } + + [Then(@"if the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } +} diff --git a/src/Tests.Specs.Integration/ChatCompletion/CreateChatSessionCommand.feature b/src/Tests.Specs.Integration/ChatCompletion/CreateChatSessionCommand.feature new file mode 100644 index 0000000..ec9bab3 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/CreateChatSessionCommand.feature @@ -0,0 +1,20 @@ +@createChatSessionCommand +Feature: Create Chat Session Command +As a chat user +When I start a new chat session and enter an initial message +Then I should see the chat session created with the initial response + +Scenario: Create Chat Session + Given I have a def "" + And I have a initial message "" + And I have a chat session id "" + And The chat session exists "" + When I create a chat session with the message + Then I see the chat session created with the initial response "" + And if the response has validation issues I see the "" in the response + +Examples: + | def | response | responseErrors | id | chatSessionExists | message | + | success | Success | | 00000000-0000-0000-0000-000000000000 | false | Hello, I am interested in an interactive chat session. | + | bad request: empty message | BadRequest | Message | 00000000-0000-0000-0000-000000000000 | false | | + | already exists | Error | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | Hello, I am interested in an interactive chat session. | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/ChatCompletion/CreateChatSessionCommand.feature.cs b/src/Tests.Specs.Integration/ChatCompletion/CreateChatSessionCommand.feature.cs new file mode 100644 index 0000000..89d0ded --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/CreateChatSessionCommand.feature.cs @@ -0,0 +1,186 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class CreateChatSessionCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "createChatSessionCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "ChatCompletion", "Create Chat Session Command", "As a chat user\r\nWhen I start a new chat session and enter an initial message\r\nThe" + + "n I should see the chat session created with the initial response", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "CreateChatSessionCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("ChatCompletion/CreateChatSessionCommand.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Create Chat Session")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Create Chat Session")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Create Chat Session Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("createChatSessionCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "00000000-0000-0000-0000-000000000000", "false", "Hello, I am interested in an interactive chat session.", "0", null, DisplayName="CreateChatSession(success,Success,,00000000-0000-0000-0000-000000000000,false,Hel" + + "lo, I am interested in an interactive chat session.,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty message", "BadRequest", "Message", "00000000-0000-0000-0000-000000000000", "false", "", "1", null, DisplayName="CreateChatSession(bad request: empty message,BadRequest,Message,00000000-0000-000" + + "0-0000-000000000000,false,,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("already exists", "Error", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "Hello, I am interested in an interactive chat session.", "2", null, DisplayName="CreateChatSession(already exists,Error,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true" + + ",Hello, I am interested in an interactive chat session.,2)")] + public async global::System.Threading.Tasks.Task CreateChatSession(string def, string response, string responseErrors, string id, string chatSessionExists, string message, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("chatSessionExists", chatSessionExists); + argumentsOfScenario.Add("message", message); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Create Chat Session", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a initial message \"{0}\"", message), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a chat session id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("The chat session exists \"{0}\"", chatSessionExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.WhenAsync("I create a chat session with the message", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 13 + await testRunner.ThenAsync(string.Format("I see the chat session created with the initial response \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("if the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/ChatCompletion/CreateChatSessionCommandStepDefinitions.cs b/src/Tests.Specs.Integration/ChatCompletion/CreateChatSessionCommandStepDefinitions.cs new file mode 100644 index 0000000..e9048be --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/CreateChatSessionCommandStepDefinitions.cs @@ -0,0 +1,93 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Domain.Actor; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion; + +[Binding] +[Scope(Tag = "createChatSessionCommand")] +public class CreateChatSessionCommandStepDefinitions : TestBase +{ + private string _message = string.Empty; + private Guid _id; + private readonly Guid _authorId = Guid.NewGuid(); + private bool _exists; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a initial message ""([^""]*)""")] + public void GivenIHaveAInitialMessage(string message) + { + _message = message; + } + + [Given(@"I have a chat session id ""([^""]*)""")] + public void GivenIHaveAChatSessionKey(string id) + { + _id = Guid.Parse(id); + } + + [Given(@"The chat session exists ""([^""]*)""")] + public void GivenTheChatSessionExists(string exists) + { + _exists = bool.Parse(exists); + } + + [When(@"I create a chat session with the message")] + public async Task WhenICreateAChatSessionWithTheMessage() + { + // Setup the database if want to test existing records + var actor = ActorEntity.Create(_authorId, _authorId, Guid.NewGuid(), "Test", "Actor", "actor@goodtocode.com"); + context.Actors.Add(actor); + if (_exists) + { + var chatSession = ChatSessionEntity.Create(_id, _authorId, "Test Session", ChatMessageRole.assistant, _message, "First Response"); + context.ChatSessions.Add(chatSession); + } + await context.SaveChangesAsync(CancellationToken.None); + + // Test command + var request = new CreateChatSessionCommand() + { + Id = _id, + Title = def, + ActorId = _authorId, + Message = _message + }; + + var validator = new CreateChatSessionCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + { + try + { + var handler = new CreateChatSessionCommandHandler(kernel, context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"I see the chat session created with the initial response ""([^""]*)""")] + public void ThenISeeTheChatSessionCreatedWithTheInitialResponse(string response) + { + HandleHasResponseType(response); + } + + [Then(@"if the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } +} diff --git a/src/Tests.Specs.Integration/ChatCompletion/DeleteChatSessionCommand.feature b/src/Tests.Specs.Integration/ChatCompletion/DeleteChatSessionCommand.feature new file mode 100644 index 0000000..191bb49 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/DeleteChatSessionCommand.feature @@ -0,0 +1,19 @@ +@deleteChatSessionCommand +Feature: Delete Chat Session Command +As a chat session owner +When I select a chat session +I can delete the chat session + +Scenario: Delete Chat Session + Given I have a def "" + And I have a chat session id"" + And The chat session exists "" + When I delete the chat session + Then The response is "" + And If the response has validation issues I see the "" in the response + +Examples: + | def | response | responseErrors | id | exists | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | + | bad request: empty id | BadRequest | Id | 00000000-0000-0000-0000-000000000000 | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/ChatCompletion/DeleteChatSessionCommand.feature.cs b/src/Tests.Specs.Integration/ChatCompletion/DeleteChatSessionCommand.feature.cs new file mode 100644 index 0000000..912cc37 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/DeleteChatSessionCommand.feature.cs @@ -0,0 +1,181 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class DeleteChatSessionCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "deleteChatSessionCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "ChatCompletion", "Delete Chat Session Command", "As a chat session owner\r\nWhen I select a chat session\r\nI can delete the chat sess" + + "ion", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "DeleteChatSessionCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("ChatCompletion/DeleteChatSessionCommand.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Delete Chat Session")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Delete Chat Session")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Delete Chat Session Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("deleteChatSessionCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="DeleteChatSession(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="DeleteChatSession(not found,NotFound,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false," + + "1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty id", "BadRequest", "Id", "00000000-0000-0000-0000-000000000000", "false", "2", null, DisplayName="DeleteChatSession(bad request: empty id,BadRequest,Id,00000000-0000-0000-0000-000" + + "000000000,false,2)")] + public async global::System.Threading.Tasks.Task DeleteChatSession(string def, string response, string responseErrors, string id, string exists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("exists", exists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Delete Chat Session", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a chat session id\"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("The chat session exists \"{0}\"", exists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I delete the chat session", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/ChatCompletion/DeleteChatSessionCommandStepDefinitions.cs b/src/Tests.Specs.Integration/ChatCompletion/DeleteChatSessionCommandStepDefinitions.cs new file mode 100644 index 0000000..3513f37 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/DeleteChatSessionCommandStepDefinitions.cs @@ -0,0 +1,76 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + [Binding] + [Scope(Tag = "deleteChatSessionCommand")] + public class DeleteChatSessionCommandStepDefinitions : TestBase + { + private Guid _id; + private bool _exists; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a chat session id""([^""]*)""")] + public void GivenIHaveAChatSessionKey(string id) + { + Guid.TryParse(id, out _id).ShouldBeTrue(); + } + + [Given(@"The chat session exists ""([^""]*)""")] + public void GivenTheChatSessionExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I delete the chat session")] + public async Task WhenIDeleteTheChatSession() + { + var request = new DeleteChatSessionCommand() + { + Id = _id + }; + + if (_exists) + { + var chatSession = ChatSessionEntity.Create(_id, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + var validator = new DeleteChatSessionCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + try + { + var handler = new DeleteChatSessionCommandHandler(context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + } +} diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatMessageQuery.feature b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessageQuery.feature new file mode 100644 index 0000000..f45be62 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessageQuery.feature @@ -0,0 +1,20 @@ +@getChatMessageQuery +Feature: Get Chat Message Query +As a chat user +When I select an existing Chat Message +I can see the chat history messages + +Scenario: Get Chat Message + Given I have a definition "" + And I have a Chat Message id "" + And The Chat Message exists "" + When I get a Chat Message + Then The response is "" + And If the response has validation issues I see the "" in the response + And If the response is successful the response has a Id + +Examples: + | def | response | responseErrors | id | chatMessageExists | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | + | bad request: empty id | BadRequest | Id | 00000000-0000-0000-0000-000000000000 | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatMessageQuery.feature.cs b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessageQuery.feature.cs new file mode 100644 index 0000000..bcd3fab --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessageQuery.feature.cs @@ -0,0 +1,183 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetChatMessageQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getChatMessageQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "ChatCompletion", "Get Chat Message Query", "As a chat user\r\nWhen I select an existing Chat Message\r\nI can see the chat histor" + + "y messages", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetChatMessageQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("ChatCompletion/GetChatMessageQuery.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get Chat Message")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get Chat Message")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Chat Message Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getChatMessageQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="GetChatMessage(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="GetChatMessage(not found,NotFound,,048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty id", "BadRequest", "Id", "00000000-0000-0000-0000-000000000000", "false", "2", null, DisplayName="GetChatMessage(bad request: empty id,BadRequest,Id,00000000-0000-0000-0000-000000" + + "000000,false,2)")] + public async global::System.Threading.Tasks.Task GetChatMessage(string def, string response, string responseErrors, string id, string chatMessageExists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("chatMessageExists", chatMessageExists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get Chat Message", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a Chat Message id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("The Chat Message exists \"{0}\"", chatMessageExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I get a Chat Message", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync("If the response is successful the response has a Id", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatMessageQueryStepDefinitions.cs b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessageQueryStepDefinitions.cs new file mode 100644 index 0000000..781fa8f --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessageQueryStepDefinitions.cs @@ -0,0 +1,91 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion; + +[Binding] +[Scope(Tag = "getChatMessageQuery")] +public class GetChatMessageQueryStepDefinitions : TestBase +{ + private Guid _id; + private bool _exists; + private readonly Guid _chatSessionId = Guid.NewGuid(); + private ChatMessageDto? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"I have a Chat Message id ""([^""]*)""")] + public void GivenIHaveAChatMessageId(string ChatMessageKey) + { + if (string.IsNullOrWhiteSpace(ChatMessageKey)) return; + Guid.TryParse(ChatMessageKey, out _id).ShouldBeTrue(); + } + + [Given(@"The Chat Message exists ""([^""]*)""")] + public void GivenITheChatMessageExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I get a Chat Message")] + public async Task WhenIGetAChatMessage() + { + if (_exists) + { + var chatSession = ChatSessionEntity.Create(_chatSessionId, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + chatSession.Messages.Add(ChatMessageEntity.Create(_id, _chatSessionId, ChatMessageRole.user, "Test Message Content")); + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetChatMessageQuery() + { + Id = _id + }; + + var validator = new GetChatMessageQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetChatMessageQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"If the response is successful the response has a Id")] + public void ThenIfTheResponseIsSuccessfulTheResponseHasAId() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Id.ShouldNotBeEmpty(); + } + + [Then(@"If the response is successful the response has a count matching ""([^""]*)""")] + public void ThenIfTheResponseIsSuccessfulTheResponseHasACountMatching(string messageContent) + { + _response?.Content?.ShouldBe(messageContent); + } +} diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesPaginatedQuery.feature b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesPaginatedQuery.feature new file mode 100644 index 0000000..d8a379d --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesPaginatedQuery.feature @@ -0,0 +1,33 @@ +@getChatMessagesPaginatedQuery +Feature: Get Chat Messages Paginated Query +As an owner of Chat Messages +When I get Chat Messages all or by date range +I can get a paginated collection of Chat Messages + +Scenario: Get Chat Messages paginated + Given I have a definition "" + And Chat Messages exist "" + And I have a start date "" + And I have a end date "" + And Chat Messages within the date range exists "" + And I have a page number "" + And I have a page size "" + When I get the Chat Messages paginated + Then The response is "" + And If the response has validation issues I see the "" in the response + And The response has a collection of Chat Messages + And Each Chat Message has a Key + And Each Chat Message has a Date greater than start date + And Each Chat Message has a Date less than end date + And The response has a Page Number + And The response has a Total Pages + And The response has a Total Count + + +Examples: + | def | response | responseErrors | startDate | endDate | exist | ChatMessagesResultExists | pageNumber | pageSize | + | success no date range | Success | | | | true | true | 1 | 10 | + | success with date range | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | true | 1 | 10 | + | success empty results | Success | | | | false | false | 1 | 10 | + | bad request page number zero | BadRequest | PageNumber | | | false | false | 0 | 10 | + | bad request page size zero | BadRequest | PageSize | | | false | false | 1 | 0 | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesPaginatedQuery.feature.cs b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesPaginatedQuery.feature.cs new file mode 100644 index 0000000..2749325 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesPaginatedQuery.feature.cs @@ -0,0 +1,221 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetChatMessagesPaginatedQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getChatMessagesPaginatedQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "ChatCompletion", "Get Chat Messages Paginated Query", "As an owner of Chat Messages\r\nWhen I get Chat Messages all or by date range\r\nI ca" + + "n get a paginated collection of Chat Messages", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetChatMessagesPaginatedQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("ChatCompletion/GetChatMessagesPaginatedQuery.feature.ndjson", 7); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get Chat Messages paginated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get Chat Messages paginated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Chat Messages Paginated Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getChatMessagesPaginatedQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success no date range", "Success", "", "", "", "true", "true", "1", "10", "0", null, DisplayName="GetChatMessagesPaginated(success no date range,Success,,,,true,true,1,10,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", "10", "1", null, DisplayName="GetChatMessagesPaginated(success with date range,Success,,2024-06-01T11:21:00Z,20" + + "34-06-03T11:21:00Z,true,true,1,10,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success empty results", "Success", "", "", "", "false", "false", "1", "10", "2", null, DisplayName="GetChatMessagesPaginated(success empty results,Success,,,,false,false,1,10,2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request page number zero", "BadRequest", "PageNumber", "", "", "false", "false", "0", "10", "3", null, DisplayName="GetChatMessagesPaginated(bad request page number zero,BadRequest,PageNumber,,,fal" + + "se,false,0,10,3)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request page size zero", "BadRequest", "PageSize", "", "", "false", "false", "1", "0", "4", null, DisplayName="GetChatMessagesPaginated(bad request page size zero,BadRequest,PageSize,,,false,f" + + "alse,1,0,4)")] + public async global::System.Threading.Tasks.Task GetChatMessagesPaginated(string def, string response, string responseErrors, string startDate, string endDate, string exist, string chatMessagesResultExists, string pageNumber, string pageSize, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("startDate", startDate); + argumentsOfScenario.Add("endDate", endDate); + argumentsOfScenario.Add("exist", exist); + argumentsOfScenario.Add("ChatMessagesResultExists", chatMessagesResultExists); + argumentsOfScenario.Add("pageNumber", pageNumber); + argumentsOfScenario.Add("pageSize", pageSize); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get Chat Messages paginated", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("Chat Messages exist \"{0}\"", exist), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a start date \"{0}\"", startDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a end date \"{0}\"", endDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("Chat Messages within the date range exists \"{0}\"", chatMessagesResultExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("I have a page number \"{0}\"", pageNumber), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("I have a page size \"{0}\"", pageSize), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 15 + await testRunner.WhenAsync("I get the Chat Messages paginated", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 16 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 17 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 18 + await testRunner.AndAsync("The response has a collection of Chat Messages", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 19 + await testRunner.AndAsync("Each Chat Message has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 20 + await testRunner.AndAsync("Each Chat Message has a Date greater than start date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 21 + await testRunner.AndAsync("Each Chat Message has a Date less than end date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 22 + await testRunner.AndAsync("The response has a Page Number", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 23 + await testRunner.AndAsync("The response has a Total Pages", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 24 + await testRunner.AndAsync("The response has a Total Count", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesPaginatedQueryStepDefinitions.cs b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesPaginatedQueryStepDefinitions.cs new file mode 100644 index 0000000..872cfe4 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesPaginatedQueryStepDefinitions.cs @@ -0,0 +1,160 @@ +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + [Binding] + [Scope(Tag = "getChatMessagesPaginatedQuery")] + public class GetChatMessagesPaginatedQueryStepDefinitions : TestBase + { + private bool _exists; + private readonly Guid _chatSessionId = Guid.NewGuid(); + private DateTime _startDate; + private DateTime _endDate; + private bool _withinDateRangeExists; + private int _pageNumber; + private int _pageSize; + private PaginatedList? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"Chat Messages exist ""([^""]*)""")] + public void GivenChatMessagesExist(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"I have a start date ""([^""]*)""")] + public void GivenIHaveAStartDate(string startDate) + { + if (string.IsNullOrWhiteSpace(startDate)) return; + DateTime.TryParse(startDate, out _startDate).ShouldBeTrue(); + } + + [Given(@"I have a end date ""([^""]*)""")] + public void GivenIHaveAEndDate(string endDate) + { + if (string.IsNullOrWhiteSpace(endDate)) return; + DateTime.TryParse(endDate, out _endDate).ShouldBeTrue(); + } + + [Given(@"Chat Messages within the date range exists ""([^""]*)""")] + public void GivenChatMessagesWithinTheDateRangeExists(string withinDateRangeExists) + { + bool.TryParse(withinDateRangeExists, out _withinDateRangeExists).ShouldBeTrue(); + } + + [Given(@"I have a page number ""([^""]*)""")] + public void GivenIHaveAPageNumber(string pageNumber) + { + int.TryParse(pageNumber, out _pageNumber).ShouldBeTrue(); + } + + [Given(@"I have a page size ""([^""]*)""")] + public void GivenIHaveAPageSize(string pageSize) + { + int.TryParse(pageSize, out _pageSize).ShouldBeTrue(); ; + } + + [When(@"I get the Chat Messages paginated")] + public async Task WhenIGetTheChatMessagesPaginated() + { + if (_exists) + { + var chatSession = ChatSessionEntity.Create(_chatSessionId, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetChatMessagesPaginatedQuery() + { + PageNumber = _pageNumber, + PageSize = _pageSize, + StartDate = _startDate == default ? null : _startDate, + EndDate = _endDate == default ? null : _endDate + }; + + var validator = new GetChatMessagesPaginatedQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetChatMessagesPaginatedQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"The response has a collection of Chat Messages")] + public void ThenTheResponseHasACollectionOfChatMessages() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalCount.ShouldBe(_withinDateRangeExists == false ? 0 : _response.TotalCount); + } + + [Then(@"Each Chat Message has a Key")] + public void ThenEachChatMessageHasAKey() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Items.FirstOrDefault(x => x.Id == default).ShouldBeNull(); + } + + [Then(@"Each Chat Message has a Date greater than start date")] + public void ThenEachChatMessageHasADateGreaterThanStartDate() + { + if (responseType == CommandResponseType.Successful && _withinDateRangeExists) + _response?.Items.FirstOrDefault(x => _startDate == default || x.Timestamp > _startDate).ShouldNotBeNull(); + } + + [Then(@"Each Chat Message has a Date less than end date")] + public void ThenEachChatMessageHasADateLessThanEndDate() + { + if (responseType == CommandResponseType.Successful && _withinDateRangeExists) + _response?.Items.FirstOrDefault(x => _endDate == default || x.Timestamp < _endDate).ShouldNotBeNull(); + } + + [Then(@"The response has a Page Number")] + public void ThenTheResponseHasAPageNumber() + { + if (responseType != CommandResponseType.Successful) return; + _response?.PageNumber.Should(); + } + + [Then(@"The response has a Total Pages")] + public void ThenTheResponseHasATotalPages() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalPages.Should(); + } + + [Then(@"The response has a Total Count")] + public void ThenTheResponseHasATotalCount() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalCount.Should(); + } + } +} diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesQuery.feature b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesQuery.feature new file mode 100644 index 0000000..f173f52 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesQuery.feature @@ -0,0 +1,26 @@ +@getChatMessagesQuery +Feature: Get Chat Messages Query +As a message owner +When I query Chat Messages optionally by date range +I get all messages that fit the date range + +Scenario: Get Chat Messages + Given I have a definition "" + And Chat Messages exist "" + And Chat Messages within the date range exists "" + And I have a start date "" + And I have a end date "" + When I get the Chat Messages + Then The response is "" + And If the response has validation issues I see the "" in the response + And The response has a collection of Chat Messages + And Each Chat Message has a Key + And Each Chat Message has a Date greater than start date + And Each Chat Message has a Date less than end date + +Examples: + | def | response | responseErrors | startDate | endDate | exist | ChatMessagesResultExists | + | success no date range | Success | | | | true | true | + | success with date range | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | true | + | success filtered results | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | false | + | success empty results | Success | | | | false | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesQuery.feature.cs b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesQuery.feature.cs new file mode 100644 index 0000000..d220393 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesQuery.feature.cs @@ -0,0 +1,202 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetChatMessagesQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getChatMessagesQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "ChatCompletion", "Get Chat Messages Query", "As a message owner\r\nWhen I query Chat Messages optionally by date range\r\nI get al" + + "l messages that fit the date range", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetChatMessagesQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("ChatCompletion/GetChatMessagesQuery.feature.ndjson", 6); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get Chat Messages")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get Chat Messages")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Chat Messages Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getChatMessagesQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success no date range", "Success", "", "", "", "true", "true", "0", null, DisplayName="GetChatMessages(success no date range,Success,,,,true,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", null, DisplayName="GetChatMessages(success with date range,Success,,2024-06-01T11:21:00Z,2034-06-03T" + + "11:21:00Z,true,true,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success filtered results", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "false", "2", null, DisplayName="GetChatMessages(success filtered results,Success,,2024-06-01T11:21:00Z,2034-06-03" + + "T11:21:00Z,true,false,2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success empty results", "Success", "", "", "", "false", "false", "3", null, DisplayName="GetChatMessages(success empty results,Success,,,,false,false,3)")] + public async global::System.Threading.Tasks.Task GetChatMessages(string def, string response, string responseErrors, string startDate, string endDate, string exist, string chatMessagesResultExists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("startDate", startDate); + argumentsOfScenario.Add("endDate", endDate); + argumentsOfScenario.Add("exist", exist); + argumentsOfScenario.Add("ChatMessagesResultExists", chatMessagesResultExists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get Chat Messages", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("Chat Messages exist \"{0}\"", exist), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("Chat Messages within the date range exists \"{0}\"", chatMessagesResultExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a start date \"{0}\"", startDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("I have a end date \"{0}\"", endDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.WhenAsync("I get the Chat Messages", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 14 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 15 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 16 + await testRunner.AndAsync("The response has a collection of Chat Messages", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 17 + await testRunner.AndAsync("Each Chat Message has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 18 + await testRunner.AndAsync("Each Chat Message has a Date greater than start date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 19 + await testRunner.AndAsync("Each Chat Message has a Date less than end date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesQueryStepDefinitions.cs b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesQueryStepDefinitions.cs new file mode 100644 index 0000000..ee64e07 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatMessagesQueryStepDefinitions.cs @@ -0,0 +1,120 @@ +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion; + +[Binding] +[Scope(Tag = "getChatMessagesQuery")] +public class GetChatMessagesQueryStepDefinitions : TestBase +{ + private bool _exists; + private bool _withinDateRangeExists; + private DateTime _endDate; + private DateTime _startDate; + private readonly Guid _chatSessionId = Guid.NewGuid(); + private ICollection? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"Chat Messages exist ""([^""]*)""")] + public void GivenChatMessagesExist(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"Chat Messages within the date range exists ""([^""]*)""")] + public void GivenChatMessagesWithinTheDateRangeExists(string withinDateRangeExists) + { + bool.TryParse(withinDateRangeExists, out _withinDateRangeExists).ShouldBeTrue(); + } + + [Given(@"I have a start date ""([^""]*)""")] + public void GivenIHaveAStartDate(string startDate) + { + if (string.IsNullOrWhiteSpace(startDate)) return; + DateTime.TryParse(startDate, out _startDate).ShouldBeTrue(); + _startDate = DateTime.UtcNow.AddMinutes(_withinDateRangeExists ? -1 : 1); //Handle for desired not-found scenarios + } + + [Given(@"I have a end date ""([^""]*)""")] + public void GivenIHaveAEndDate(string endDate) + { + if (string.IsNullOrWhiteSpace(endDate)) return; + DateTime.TryParse(endDate, out _endDate).ShouldBeTrue(); + } + + [When(@"I get the Chat Messages")] + public async Task WhenIGetTheChatMessages() + { + if (_exists) + { + var chatSession = ChatSessionEntity.Create(_chatSessionId, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetChatMessagesQuery() + { + StartDate = _startDate == default ? null : _startDate, + EndDate = _endDate == default ? null : _endDate + }; + + var validator = new GetChatMessagesQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetChatMessagesQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"The response has a collection of Chat Messages")] + public void ThenTheResponseHasACollectionOfChatMessages() + { + _response?.Count.ShouldBe(_withinDateRangeExists == false ? 0 : _response.Count); + } + + [Then(@"Each Chat Message has a Key")] + public void ThenEachChatMessageHasAKey() + { + _response?.FirstOrDefault(x => x.Id == default).ShouldBeNull(); + } + + [Then(@"Each Chat Message has a Date greater than start date")] + public void ThenEachChatMessageHasADateGreaterThanStartDate() + { + if (_withinDateRangeExists) + _response?.FirstOrDefault(x => _startDate == default || x.Timestamp > _startDate).ShouldNotBeNull(); + } + + [Then(@"Each Chat Message has a Date less than end date")] + public void ThenEachChatMessageHasADateLessThanEndDate() + { + if (_withinDateRangeExists) + _response?.FirstOrDefault(x => _endDate == default || x.Timestamp < _endDate).ShouldNotBeNull(); + } +} diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionQuery.feature b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionQuery.feature new file mode 100644 index 0000000..9e82114 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionQuery.feature @@ -0,0 +1,22 @@ +@getChatSessionQuery +Feature: Get Chat Session Query +As a chat user +When I select an existing chat session +I can see the chat history messages + +Scenario: Get chat session + Given I have a definition "" + And I have a chat session id "" + And I the chat session exists "" + And I have a expected chat session count "" + When I get a chat session + Then The response is "" + And If the response has validation issues I see the "" in the response + And If the response is successful the response has a Id + And If the response is successful the response has a count matching "" + +Examples: + | def | response | responseErrors | id | chatSessionExists | expectedChatSessionCount | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | 2 | + | not found | NotFound | | 048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | 0 | + | bad request: empty id | BadRequest | Id | 00000000-0000-0000-0000-000000000000 | false | 0 | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionQuery.feature.cs b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionQuery.feature.cs new file mode 100644 index 0000000..f16ff79 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionQuery.feature.cs @@ -0,0 +1,191 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetChatSessionQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getChatSessionQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "ChatCompletion", "Get Chat Session Query", "As a chat user\r\nWhen I select an existing chat session\r\nI can see the chat histor" + + "y messages", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetChatSessionQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("ChatCompletion/GetChatSessionQuery.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get chat session")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get chat session")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Chat Session Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getChatSessionQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "2", "0", null, DisplayName="GetChatSession(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,2,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "0", "1", null, DisplayName="GetChatSession(not found,NotFound,,048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false,0,1" + + ")")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty id", "BadRequest", "Id", "00000000-0000-0000-0000-000000000000", "false", "0", "2", null, DisplayName="GetChatSession(bad request: empty id,BadRequest,Id,00000000-0000-0000-0000-000000" + + "000000,false,0,2)")] + public async global::System.Threading.Tasks.Task GetChatSession(string def, string response, string responseErrors, string id, string chatSessionExists, string expectedChatSessionCount, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("chatSessionExists", chatSessionExists); + argumentsOfScenario.Add("expectedChatSessionCount", expectedChatSessionCount); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get chat session", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a chat session id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I the chat session exists \"{0}\"", chatSessionExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a expected chat session count \"{0}\"", expectedChatSessionCount), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.WhenAsync("I get a chat session", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 13 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 15 + await testRunner.AndAsync("If the response is successful the response has a Id", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 16 + await testRunner.AndAsync(string.Format("If the response is successful the response has a count matching \"{0}\"", expectedChatSessionCount), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionQueryStepDefinitions.cs b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionQueryStepDefinitions.cs new file mode 100644 index 0000000..50d53a1 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionQueryStepDefinitions.cs @@ -0,0 +1,97 @@ +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; +using System.Globalization; +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion; + +[Binding] +[Scope(Tag = "getChatSessionQuery")] +public class GetChatSessionQueryStepDefinitions : TestBase +{ + private Guid _id; + private bool _exists; + private int _chatSessionCount; + private ChatSessionDto? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"I have a chat session id ""([^""]*)""")] + public void GivenIHaveAChatSessionId(string chatSessionKey) + { + if (string.IsNullOrWhiteSpace(chatSessionKey)) return; + Guid.TryParse(chatSessionKey, out _id).ShouldBeTrue(); + } + + [Given(@"I the chat session exists ""([^""]*)""")] + public void GivenITheChatSessionExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"I have a expected chat session count ""([^""]*)""")] + public void GivenIHaveAExpectedChatSessionCount(string chatSessionCount) + { + _chatSessionCount = int.Parse(chatSessionCount, CultureInfo.InvariantCulture); + } + + [When(@"I get a chat session")] + public async Task WhenIGetAChatSession() + { + if (_exists) + { + var chatSession = ChatSessionEntity.Create(_id, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetChatSessionQuery() + { + Id = _id + }; + + var validator = new GetChatSessionQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetChatSessionQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"If the response is successful the response has a Id")] + public void ThenIfTheResponseIsSuccessfulTheResponseHasAId() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Id.ShouldNotBeEmpty(); + } + + [Then(@"If the response is successful the response has a count matching ""([^""]*)""")] + public void ThenIfTheResponseIsSuccessfulTheResponseHasACountMatching(string messageCount) + { + _response?.Messages?.Count.ShouldBe(int.Parse(messageCount, CultureInfo.InvariantCulture)); + } +} diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsPaginatedQuery.feature b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsPaginatedQuery.feature new file mode 100644 index 0000000..5ea6cbc --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsPaginatedQuery.feature @@ -0,0 +1,33 @@ +@getChatSessionsPaginatedQuery +Feature: Get Chat Sessions Paginated Query +As an owner of chat sessions +When I get chat sessions all or by date range +I can get a paginated collection of chat sessions + +Scenario: Get chat sessions paginated + Given I have a definition "" + And Chat Sessions exist "" + And I have a start date "" + And I have a end date "" + And chat sessions within the date range exists "" + And I have a page number "" + And I have a page size "" + When I get the chat sessions paginated + Then The response is "" + And If the response has validation issues I see the "" in the response + And The response has a collection of chat sessions + And Each chat session has a Key + And Each chat session has a Date greater than start date + And Each chat session has a Date less than end date + And The response has a Page Number + And The response has a Total Pages + And The response has a Total Count + + +Examples: + | def | response | responseErrors | startDate | endDate | exist | chatSessionsResultExists | pageNumber | pageSize | + | success no date range | Success | | | | true | true | 1 | 10 | + | success with date range | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | true | 1 | 10 | + | success empty results | Success | | | | false | false | 1 | 10 | + | bad request page number zero | BadRequest | PageNumber | | | false | false | 0 | 10 | + | bad request page size zero | BadRequest | PageSize | | | false | false | 1 | 0 | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsPaginatedQuery.feature.cs b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsPaginatedQuery.feature.cs new file mode 100644 index 0000000..82c06f3 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsPaginatedQuery.feature.cs @@ -0,0 +1,221 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetChatSessionsPaginatedQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getChatSessionsPaginatedQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "ChatCompletion", "Get Chat Sessions Paginated Query", "As an owner of chat sessions\r\nWhen I get chat sessions all or by date range\r\nI ca" + + "n get a paginated collection of chat sessions", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetChatSessionsPaginatedQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("ChatCompletion/GetChatSessionsPaginatedQuery.feature.ndjson", 7); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get chat sessions paginated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get chat sessions paginated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Chat Sessions Paginated Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getChatSessionsPaginatedQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success no date range", "Success", "", "", "", "true", "true", "1", "10", "0", null, DisplayName="GetChatSessionsPaginated(success no date range,Success,,,,true,true,1,10,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", "10", "1", null, DisplayName="GetChatSessionsPaginated(success with date range,Success,,2024-06-01T11:21:00Z,20" + + "34-06-03T11:21:00Z,true,true,1,10,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success empty results", "Success", "", "", "", "false", "false", "1", "10", "2", null, DisplayName="GetChatSessionsPaginated(success empty results,Success,,,,false,false,1,10,2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request page number zero", "BadRequest", "PageNumber", "", "", "false", "false", "0", "10", "3", null, DisplayName="GetChatSessionsPaginated(bad request page number zero,BadRequest,PageNumber,,,fal" + + "se,false,0,10,3)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request page size zero", "BadRequest", "PageSize", "", "", "false", "false", "1", "0", "4", null, DisplayName="GetChatSessionsPaginated(bad request page size zero,BadRequest,PageSize,,,false,f" + + "alse,1,0,4)")] + public async global::System.Threading.Tasks.Task GetChatSessionsPaginated(string def, string response, string responseErrors, string startDate, string endDate, string exist, string chatSessionsResultExists, string pageNumber, string pageSize, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("startDate", startDate); + argumentsOfScenario.Add("endDate", endDate); + argumentsOfScenario.Add("exist", exist); + argumentsOfScenario.Add("chatSessionsResultExists", chatSessionsResultExists); + argumentsOfScenario.Add("pageNumber", pageNumber); + argumentsOfScenario.Add("pageSize", pageSize); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get chat sessions paginated", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("Chat Sessions exist \"{0}\"", exist), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a start date \"{0}\"", startDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a end date \"{0}\"", endDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("chat sessions within the date range exists \"{0}\"", chatSessionsResultExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("I have a page number \"{0}\"", pageNumber), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("I have a page size \"{0}\"", pageSize), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 15 + await testRunner.WhenAsync("I get the chat sessions paginated", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 16 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 17 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 18 + await testRunner.AndAsync("The response has a collection of chat sessions", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 19 + await testRunner.AndAsync("Each chat session has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 20 + await testRunner.AndAsync("Each chat session has a Date greater than start date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 21 + await testRunner.AndAsync("Each chat session has a Date less than end date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 22 + await testRunner.AndAsync("The response has a Page Number", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 23 + await testRunner.AndAsync("The response has a Total Pages", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 24 + await testRunner.AndAsync("The response has a Total Count", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsPaginatedQueryStepDefinitions.cs b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsPaginatedQueryStepDefinitions.cs new file mode 100644 index 0000000..c81bb54 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsPaginatedQueryStepDefinitions.cs @@ -0,0 +1,159 @@ +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + [Binding] + [Scope(Tag = "getChatSessionsPaginatedQuery")] + public class GetChatSessionsPaginatedQueryStepDefinitions : TestBase + { + private bool _exists; + private DateTime _startDate; + private DateTime _endDate; + private bool _withinDateRangeExists; + private int _pageNumber; + private int _pageSize; + private PaginatedList? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"Chat Sessions exist ""([^""]*)""")] + public void GivenChatSessionsExist(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"I have a start date ""([^""]*)""")] + public void GivenIHaveAStartDate(string startDate) + { + if (string.IsNullOrWhiteSpace(startDate)) return; + DateTime.TryParse(startDate, out _startDate).ShouldBeTrue(); + } + + [Given(@"I have a end date ""([^""]*)""")] + public void GivenIHaveAEndDate(string endDate) + { + if (string.IsNullOrWhiteSpace(endDate)) return; + DateTime.TryParse(endDate, out _endDate).ShouldBeTrue(); + } + + [Given(@"chat sessions within the date range exists ""([^""]*)""")] + public void GivenChatSessionsWithinTheDateRangeExists(string withinDateRangeExists) + { + bool.TryParse(withinDateRangeExists, out _withinDateRangeExists).ShouldBeTrue(); + } + + [Given(@"I have a page number ""([^""]*)""")] + public void GivenIHaveAPageNumber(string pageNumber) + { + int.TryParse(pageNumber, out _pageNumber).ShouldBeTrue(); + } + + [Given(@"I have a page size ""([^""]*)""")] + public void GivenIHaveAPageSize(string pageSize) + { + int.TryParse(pageSize, out _pageSize).ShouldBeTrue(); ; + } + + [When(@"I get the chat sessions paginated")] + public async Task WhenIGetTheChatSessionsPaginated() + { + if (_exists) + { + var chatSession = ChatSessionEntity.Create(Guid.NewGuid(), Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetChatSessionsPaginatedQuery() + { + PageNumber = _pageNumber, + PageSize = _pageSize, + StartDate = _startDate == default ? null : _startDate, + EndDate = _endDate == default ? null : _endDate + }; + + var validator = new GetChatSessionsPaginatedQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetChatSessionsPaginatedQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"The response has a collection of chat sessions")] + public void ThenTheResponseHasACollectionOfChatSessions() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalCount.ShouldBe(_withinDateRangeExists == false ? 0 : _response.TotalCount); + } + + [Then(@"Each chat session has a Key")] + public void ThenEachChatSessionHasAKey() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Items.FirstOrDefault(x => x.Id == default).ShouldBeNull(); + } + + [Then(@"Each chat session has a Date greater than start date")] + public void ThenEachChatSessionHasADateGreaterThanStartDate() + { + if (responseType == CommandResponseType.Successful && _withinDateRangeExists) + _response?.Items.FirstOrDefault(x => _startDate == default || x.Timestamp > _startDate).ShouldNotBeNull(); + } + + [Then(@"Each chat session has a Date less than end date")] + public void ThenEachChatSessionHasADateLessThanEndDate() + { + if (responseType == CommandResponseType.Successful && _withinDateRangeExists) + _response?.Items.FirstOrDefault(x => _endDate == default || x.Timestamp < _endDate).ShouldNotBeNull(); + } + + [Then(@"The response has a Page Number")] + public void ThenTheResponseHasAPageNumber() + { + if (responseType != CommandResponseType.Successful) return; + _response?.PageNumber.Should(); + } + + [Then(@"The response has a Total Pages")] + public void ThenTheResponseHasATotalPages() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalPages.Should(); + } + + [Then(@"The response has a Total Count")] + public void ThenTheResponseHasATotalCount() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalCount.Should(); + } + } +} diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsQuery.feature b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsQuery.feature new file mode 100644 index 0000000..c795982 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsQuery.feature @@ -0,0 +1,26 @@ +@getChatSessionsQuery +Feature: Get Chat Sessions Query +As a session owner +When I query chat sessions optionally by date range +I get all sessions that fit the date range + +Scenario: Get chat sessions + Given I have a definition "" + And Chat Sessions exist "" + And chat sessions within the date range exists "" + And I have a start date "" + And I have a end date "" + When I get the chat sessions + Then The response is "" + And If the response has validation issues I see the "" in the response + And The response has a collection of chat sessions + And Each chat session has a Key + And Each chat session has a Date greater than start date + And Each chat session has a Date less than end date + +Examples: + | def | response | responseErrors | startDate | endDate | exist | chatSessionsResultExists | + | success no date range | Success | | | | true | true | + | success with date range | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | true | + | success filtered results | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | false | + | success empty results | Success | | | | false | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsQuery.feature.cs b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsQuery.feature.cs new file mode 100644 index 0000000..5f6499c --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsQuery.feature.cs @@ -0,0 +1,202 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetChatSessionsQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getChatSessionsQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "ChatCompletion", "Get Chat Sessions Query", "As a session owner\r\nWhen I query chat sessions optionally by date range\r\nI get al" + + "l sessions that fit the date range", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetChatSessionsQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("ChatCompletion/GetChatSessionsQuery.feature.ndjson", 6); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get chat sessions")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get chat sessions")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Chat Sessions Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getChatSessionsQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success no date range", "Success", "", "", "", "true", "true", "0", null, DisplayName="GetChatSessions(success no date range,Success,,,,true,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", null, DisplayName="GetChatSessions(success with date range,Success,,2024-06-01T11:21:00Z,2034-06-03T" + + "11:21:00Z,true,true,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success filtered results", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "false", "2", null, DisplayName="GetChatSessions(success filtered results,Success,,2024-06-01T11:21:00Z,2034-06-03" + + "T11:21:00Z,true,false,2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success empty results", "Success", "", "", "", "false", "false", "3", null, DisplayName="GetChatSessions(success empty results,Success,,,,false,false,3)")] + public async global::System.Threading.Tasks.Task GetChatSessions(string def, string response, string responseErrors, string startDate, string endDate, string exist, string chatSessionsResultExists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("startDate", startDate); + argumentsOfScenario.Add("endDate", endDate); + argumentsOfScenario.Add("exist", exist); + argumentsOfScenario.Add("chatSessionsResultExists", chatSessionsResultExists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get chat sessions", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("Chat Sessions exist \"{0}\"", exist), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("chat sessions within the date range exists \"{0}\"", chatSessionsResultExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a start date \"{0}\"", startDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("I have a end date \"{0}\"", endDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.WhenAsync("I get the chat sessions", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 14 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 15 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 16 + await testRunner.AndAsync("The response has a collection of chat sessions", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 17 + await testRunner.AndAsync("Each chat session has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 18 + await testRunner.AndAsync("Each chat session has a Date greater than start date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 19 + await testRunner.AndAsync("Each chat session has a Date less than end date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsQueryStepDefinitions.cs b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsQueryStepDefinitions.cs new file mode 100644 index 0000000..eedd9f6 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/GetChatSessionsQueryStepDefinitions.cs @@ -0,0 +1,119 @@ +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion; + +[Binding] +[Scope(Tag = "getChatSessionsQuery")] +public class GetChatSessionsQueryStepDefinitions : TestBase +{ + private bool _exists; + private bool _withinDateRangeExists; + private DateTime _endDate; + private DateTime _startDate; + private ICollection? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"Chat Sessions exist ""([^""]*)""")] + public void GivenChatSessionsExist(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"chat sessions within the date range exists ""([^""]*)""")] + public void GivenChatSessionsWithinTheDateRangeExists(string withinDateRangeExists) + { + bool.TryParse(withinDateRangeExists, out _withinDateRangeExists).ShouldBeTrue(); + } + + [Given(@"I have a start date ""([^""]*)""")] + public void GivenIHaveAStartDate(string startDate) + { + if (string.IsNullOrWhiteSpace(startDate)) return; + DateTime.TryParse(startDate, out _startDate).ShouldBeTrue(); + _startDate = DateTime.UtcNow.AddMinutes(_withinDateRangeExists ? -1 : 1); //Handle for desired not-found scenarios + } + + [Given(@"I have a end date ""([^""]*)""")] + public void GivenIHaveAEndDate(string endDate) + { + if (string.IsNullOrWhiteSpace(endDate)) return; + DateTime.TryParse(endDate, out _endDate).ShouldBeTrue(); + } + + [When(@"I get the chat sessions")] + public async Task WhenIGetTheChatSessions() + { + if (_exists) + { + var chatSession = ChatSessionEntity.Create(Guid.NewGuid(), Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetChatSessionsQuery() + { + StartDate = _startDate == default ? null : _startDate, + EndDate = _endDate == default ? null : _endDate + }; + + var validator = new GetChatSessionsQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetChatSessionsQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"The response has a collection of chat sessions")] + public void ThenTheResponseHasACollectionOfChatSessions() + { + _response?.Count.ShouldBe(_withinDateRangeExists == false ? 0 : _response.Count); + } + + [Then(@"Each chat session has a Key")] + public void ThenEachChatSessionHasAKey() + { + _response?.FirstOrDefault(x => x.Id == default).ShouldBeNull(); + } + + [Then(@"Each chat session has a Date greater than start date")] + public void ThenEachChatSessionHasADateGreaterThanStartDate() + { + if (_withinDateRangeExists) + _response?.FirstOrDefault(x => _startDate == default || x.Timestamp > _startDate).ShouldNotBeNull(); + } + + [Then(@"Each chat session has a Date less than end date")] + public void ThenEachChatSessionHasADateLessThanEndDate() + { + if (_withinDateRangeExists) + _response?.FirstOrDefault(x => _endDate == default || x.Timestamp < _endDate).ShouldNotBeNull(); + } +} diff --git a/src/Tests.Specs.Integration/ChatCompletion/PatchChatSessionCommand.feature b/src/Tests.Specs.Integration/ChatCompletion/PatchChatSessionCommand.feature new file mode 100644 index 0000000..b08e8c7 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/PatchChatSessionCommand.feature @@ -0,0 +1,19 @@ +@patchChatSessionCommand +Feature: Patch ChatSession Command +As a creator +I can patch a chatSession + +Scenario: Patch Chat Session + Given I have a def "" + And I have a chat session id "" + And the chat session exists "" + And I have a new chat session title "" + When I patch the chatSession + Then The response is "<response>" + And If the response has validation issues I see the "<responseErrors>" in the response + +Examples: + | def | response | responseErrors | id | chatSessionExists | title | + | success : patch title | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | New Title | + | bad request: empty id | BadRequest | Id | 00000000-0000-0000-0000-000000000000 | false | Changed Title | + | not found : patch title | NotFound | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | Title | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/ChatCompletion/PatchChatSessionCommand.feature.cs b/src/Tests.Specs.Integration/ChatCompletion/PatchChatSessionCommand.feature.cs new file mode 100644 index 0000000..fa5558d --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/PatchChatSessionCommand.feature.cs @@ -0,0 +1,185 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class PatchChatSessionCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "patchChatSessionCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "ChatCompletion", "Patch ChatSession Command", "As a creator\r\nI can patch a chatSession ", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "PatchChatSessionCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("ChatCompletion/PatchChatSessionCommand.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 6, DisplayName="Patch Chat Session")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Patch Chat Session")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Patch ChatSession Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("patchChatSessionCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success : patch title", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "New Title", "0", null, DisplayName="PatchChatSession(success : patch title,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889f" + + "ed9,true,New Title,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty id", "BadRequest", "Id", "00000000-0000-0000-0000-000000000000", "false", "Changed Title", "1", null, DisplayName="PatchChatSession(bad request: empty id,BadRequest,Id,00000000-0000-0000-0000-0000" + + "00000000,false,Changed Title,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found : patch title", "NotFound", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "Title", "2", null, DisplayName="PatchChatSession(not found : patch title,NotFound,,038d8e7f-f18f-4a8e-8b3c-3b6a68" + + "89fed9,false,Title,2)")] + public async global::System.Threading.Tasks.Task PatchChatSession(string def, string response, string responseErrors, string id, string chatSessionExists, string title, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("chatSessionExists", chatSessionExists); + argumentsOfScenario.Add("title", title); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Patch Chat Session", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 6 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 7 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 8 + await testRunner.AndAsync(string.Format("I have a chat session id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("the chat session exists \"{0}\"", chatSessionExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a new chat session title \"{0}\"", title), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I patch the chatSession", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/ChatCompletion/PatchChatSessionCommandStepDefinitions.cs b/src/Tests.Specs.Integration/ChatCompletion/PatchChatSessionCommandStepDefinitions.cs new file mode 100644 index 0000000..d1814e0 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/PatchChatSessionCommandStepDefinitions.cs @@ -0,0 +1,84 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + [Binding] + [Scope(Tag = "patchChatSessionCommand")] + public class PatchChatSessionCommandStepDefinitions : TestBase + { + private Guid _id; + private bool _exists; + private string _title = string.Empty; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a chat session id ""([^""]*)""")] + public void GivenIHaveAChatSessionId(string id) + { + Guid.TryParse(id, out _id).ShouldBeTrue(); + } + + [Given(@"the chat session exists ""([^""]*)""")] + public void GivenTheChatSessionExists(string exists) + { + _exists = bool.Parse(exists); + } + + [Given(@"I have a new chat session title ""([^""]*)""")] + public void GivenIHaveANewChatSessionTitle(string title) + { + _title = title; + } + + [When(@"I patch the chatSession")] + public async Task WhenIPatchTheChatSession() + { + var request = new PatchChatSessionCommand() + { + Id = _id, + Title = _title + }; + + if (_exists) + { + var chatSession = ChatSessionEntity.Create(_id, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + var validator = new PatchChatSessionCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + try + { + var handler = new PatchChatSessionCommandHandler(context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + } +} diff --git a/src/Tests.Specs.Integration/ChatCompletion/UpdateChatSessionCommand.feature b/src/Tests.Specs.Integration/ChatCompletion/UpdateChatSessionCommand.feature new file mode 100644 index 0000000..72d8b5a --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/UpdateChatSessionCommand.feature @@ -0,0 +1,19 @@ +@updateChatSessionCommand +Feature: Update Chat Session Command +As a chat session owner +When I edit a chat session +I am able to change or add to the chat session + +Scenario: Update Chat Session + Given I have a def "<def>" + And I have a chat session id "<id>" + And the chat session exists "<exists>" + When I update the chat session + Then The response is "<response>" + And If the response has validation issues I see the "<responseErrors>" in the response + +Examples: + | def | response | responseErrors | id | exists | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | + | bad request: empty id | BadRequest | Id | 00000000-0000-0000-0000-000000000000 | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/ChatCompletion/UpdateChatSessionCommand.feature.cs b/src/Tests.Specs.Integration/ChatCompletion/UpdateChatSessionCommand.feature.cs new file mode 100644 index 0000000..34d9831 --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/UpdateChatSessionCommand.feature.cs @@ -0,0 +1,181 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class UpdateChatSessionCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "updateChatSessionCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "ChatCompletion", "Update Chat Session Command", "As a chat session owner\r\nWhen I edit a chat session\r\nI am able to change or add t" + + "o the chat session", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "UpdateChatSessionCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("ChatCompletion/UpdateChatSessionCommand.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Update Chat Session")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Update Chat Session")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Update Chat Session Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("updateChatSessionCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="UpdateChatSession(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="UpdateChatSession(not found,NotFound,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false," + + "1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty id", "BadRequest", "Id", "00000000-0000-0000-0000-000000000000", "false", "2", null, DisplayName="UpdateChatSession(bad request: empty id,BadRequest,Id,00000000-0000-0000-0000-000" + + "000000000,false,2)")] + public async global::System.Threading.Tasks.Task UpdateChatSession(string def, string response, string responseErrors, string id, string exists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("exists", exists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Update Chat Session", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a chat session id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("the chat session exists \"{0}\"", exists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I update the chat session", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/ChatCompletion/UpdateChatSessionCommandStepDefinitions.cs b/src/Tests.Specs.Integration/ChatCompletion/UpdateChatSessionCommandStepDefinitions.cs new file mode 100644 index 0000000..916ecde --- /dev/null +++ b/src/Tests.Specs.Integration/ChatCompletion/UpdateChatSessionCommandStepDefinitions.cs @@ -0,0 +1,77 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Domain.ChatCompletion; + +namespace Goodtocode.AgentFramework.Specs.Integration.ChatCompletion +{ + [Binding] + [Scope(Tag = "updateChatSessionCommand")] + public class UpdateChatSessionCommandStepDefinitions : TestBase + { + private bool _exists; + private Guid _id; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a chat session id ""([^""]*)""")] + public void GivenIHaveAChatSessionId(string id) + { + Guid.TryParse(id, out _id).ShouldBeTrue(); + } + + [Given(@"the chat session exists ""([^""]*)""")] + public void GivenTheChatSessionExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I update the chat session")] + public async Task WhenIUpdateTheChatSession() + { + var request = new UpdateChatSessionCommand() + { + Id = _id, + Title = "My Title" + }; + + if (_exists) + { + var chatSession = ChatSessionEntity.Create(_id, Guid.NewGuid(), "Test Session", ChatMessageRole.assistant, "First Message", "First Response"); + context.ChatSessions.Add(chatSession); + await context.SaveChangesAsync(CancellationToken.None); + } + + var validator = new UpdateChatSessionCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + try + { + var handler = new UpdateChatSessionCommandHandler(context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + } +} diff --git a/src/Tests.Specs.Integration/GlobalUsings.cs b/src/Tests.Specs.Integration/GlobalUsings.cs new file mode 100644 index 0000000..8db9d8f --- /dev/null +++ b/src/Tests.Specs.Integration/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using Goodtocode.Assertion; +global using Goodtocode.Validation; +global using Microsoft.EntityFrameworkCore; +global using Reqnroll; +global using System.Collections.Concurrent; diff --git a/src/Tests.Specs.Integration/Image/CreateTextToImageCommand.feature b/src/Tests.Specs.Integration/Image/CreateTextToImageCommand.feature new file mode 100644 index 0000000..ff5072d --- /dev/null +++ b/src/Tests.Specs.Integration/Image/CreateTextToImageCommand.feature @@ -0,0 +1,20 @@ +@createTextToImageCommand +Feature: Create Text To Image Command +As a actor +When I start a new text image and enter an initial prompt +Then I should see the text image created with the initial response + +Scenario: Create Text Image + Given I have a def "<def>" + And I have a initial prompt "<prompt>" + And I have a text image id "<id>" + And The text image exists "<textImageExists>" + When I create a text image with the prompt + Then I see the text image created with the initial response "<response>" + And if the response has validation issues I see the "<responseErrors>" in the response + +Examples: + | def | response | responseErrors | id | textImageExists | prompt | + | success | Success | | 00000000-0000-0000-0000-000000000000 | false | Create an image of a triangle, square and a circle. | + | bad request: empty propmt | BadRequest | Prompt | 00000000-0000-0000-0000-000000000000 | false | | + | already exists | Error | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | Create an image of a triangle, square and a circle. | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Image/CreateTextToImageCommand.feature.cs b/src/Tests.Specs.Integration/Image/CreateTextToImageCommand.feature.cs new file mode 100644 index 0000000..e3c952d --- /dev/null +++ b/src/Tests.Specs.Integration/Image/CreateTextToImageCommand.feature.cs @@ -0,0 +1,186 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Image +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class CreateTextToImageCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "createTextToImageCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Image", "Create Text To Image Command", "As a actor\r\nWhen I start a new text image and enter an initial prompt\r\nThen I sho" + + "uld see the text image created with the initial response", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "CreateTextToImageCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Image/CreateTextToImageCommand.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Create Text Image")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Create Text Image")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Create Text To Image Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("createTextToImageCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "00000000-0000-0000-0000-000000000000", "false", "Create an image of a triangle, square and a circle.", "0", null, DisplayName="CreateTextImage(success,Success,,00000000-0000-0000-0000-000000000000,false,Creat" + + "e an image of a triangle, square and a circle.,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty propmt", "BadRequest", "Prompt", "00000000-0000-0000-0000-000000000000", "false", "", "1", null, DisplayName="CreateTextImage(bad request: empty propmt,BadRequest,Prompt,00000000-0000-0000-00" + + "00-000000000000,false,,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("already exists", "Error", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "Create an image of a triangle, square and a circle.", "2", null, DisplayName="CreateTextImage(already exists,Error,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,C" + + "reate an image of a triangle, square and a circle.,2)")] + public async global::System.Threading.Tasks.Task CreateTextImage(string def, string response, string responseErrors, string id, string textImageExists, string prompt, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("textImageExists", textImageExists); + argumentsOfScenario.Add("prompt", prompt); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Create Text Image", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a initial prompt \"{0}\"", prompt), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a text image id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("The text image exists \"{0}\"", textImageExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.WhenAsync("I create a text image with the prompt", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 13 + await testRunner.ThenAsync(string.Format("I see the text image created with the initial response \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("if the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Image/CreateTextToImageCommandStepDefinitions.cs b/src/Tests.Specs.Integration/Image/CreateTextToImageCommandStepDefinitions.cs new file mode 100644 index 0000000..f1d3e43 --- /dev/null +++ b/src/Tests.Specs.Integration/Image/CreateTextToImageCommandStepDefinitions.cs @@ -0,0 +1,89 @@ +using Goodtocode.AgentFramework.Core.Application.Image; +using Goodtocode.AgentFramework.Core.Domain.Image; + +namespace Goodtocode.AgentFramework.Specs.Integration.Image; + +[Binding] +[Scope(Tag = "createTextToImageCommand")] +public class CreateTextToImageCommandStepDefinitions : TestBase +{ + private string _prompt = string.Empty; + private Guid _id; + private bool _exists; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a initial prompt ""([^""]*)""")] + public void GivenIHaveAInitialprompt(string prompt) + { + _prompt = prompt; + } + + [Given(@"I have a text image id ""([^""]*)""")] + public void GivenIHaveATextImageKey(string id) + { + _id = Guid.Parse(id); + } + + [Given(@"The text image exists ""([^""]*)""")] + public void GivenThetextImageExists(string exists) + { + _exists = bool.Parse(exists); + } + + [When(@"I create a text image with the prompt")] + public async Task WhenICreateATextImageWithTheprompt() + { + // Setup the database if want to test existing records + if (_exists) + { + var textImage = TextImageEntity.Create(Guid.NewGuid(), _prompt, 1024, 1024, new ReadOnlyMemory<byte>([0x01, 0x02, 0x03, 0x04])); + context.TextImages.Add(textImage); + await context.SaveChangesAsync(CancellationToken.None); + } + + // Test command + var request = new CreateTextToImageCommand() + { + Id = _id, + Prompt = _prompt, + Width = 1024, + Height = 1024 + }; + + var validator = new CreateTextToImageCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + { + try + { + var handler = new CreateTextToImageCommandHandler(kernel, context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"I see the text image created with the initial response ""([^""]*)""")] + public void ThenISeeTheTextImageCreatedWithTheInitialResponse(string response) + { + HandleHasResponseType(response); + } + + [Then(@"if the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } +} diff --git a/src/Tests.Specs.Integration/Image/DeleteTextImageCommand.feature b/src/Tests.Specs.Integration/Image/DeleteTextImageCommand.feature new file mode 100644 index 0000000..31e397e --- /dev/null +++ b/src/Tests.Specs.Integration/Image/DeleteTextImageCommand.feature @@ -0,0 +1,19 @@ +@deleteTextImageCommand +Feature: Delete Text Image Command +As a text image owner +When I select a text image +I can delete the text image + +Scenario: Delete Text Image + Given I have a def "<def>" + And I have a text image id"<id>" + And The text image exists "<exists>" + When I delete the text image + Then The response is "<response>" + And If the response has validation issues I see the "<responseErrors>" in the response + +Examples: + | def | response | responseErrors | id | exists | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | + | bad request: empty id | BadRequest | Id | 00000000-0000-0000-0000-000000000000 | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Image/DeleteTextImageCommand.feature.cs b/src/Tests.Specs.Integration/Image/DeleteTextImageCommand.feature.cs new file mode 100644 index 0000000..7ecc62e --- /dev/null +++ b/src/Tests.Specs.Integration/Image/DeleteTextImageCommand.feature.cs @@ -0,0 +1,180 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Image +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class DeleteTextImageCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "deleteTextImageCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Image", "Delete Text Image Command", "As a text image owner\r\nWhen I select a text image\r\nI can delete the text image", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "DeleteTextImageCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Image/DeleteTextImageCommand.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Delete Text Image")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Delete Text Image")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Delete Text Image Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("deleteTextImageCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="DeleteTextImage(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="DeleteTextImage(not found,NotFound,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false,1)" + + "")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty id", "BadRequest", "Id", "00000000-0000-0000-0000-000000000000", "false", "2", null, DisplayName="DeleteTextImage(bad request: empty id,BadRequest,Id,00000000-0000-0000-0000-00000" + + "0000000,false,2)")] + public async global::System.Threading.Tasks.Task DeleteTextImage(string def, string response, string responseErrors, string id, string exists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("exists", exists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Delete Text Image", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a text image id\"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("The text image exists \"{0}\"", exists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I delete the text image", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Image/DeleteTextImageCommandStepDefinitions.cs b/src/Tests.Specs.Integration/Image/DeleteTextImageCommandStepDefinitions.cs new file mode 100644 index 0000000..5031f48 --- /dev/null +++ b/src/Tests.Specs.Integration/Image/DeleteTextImageCommandStepDefinitions.cs @@ -0,0 +1,79 @@ +using Goodtocode.AgentFramework.Core.Application.ChatCompletion; +using Goodtocode.AgentFramework.Core.Domain.Image; + +namespace Goodtocode.AgentFramework.Specs.Integration.Image +{ + [Binding] + [Scope(Tag = "deleteTextImageCommand")] + public class DeleteTextImageCommandStepDefinitions : TestBase + { + private Guid _id; + private bool _exists; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a text image id""([^""]*)""")] + public void GivenIHaveATextImageKey(string id) + { + Guid.TryParse(id, out _id).ShouldBeTrue(); + } + + [Given(@"The text image exists ""([^""]*)""")] + public void GivenThetextImageExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I delete the text image")] + public async Task WhenIDeleteTheTextImage() + { + var request = new DeleteTextImageCommand() + { + Id = _id + }; + + if (_exists) + { + var textImage = TextImageEntity.Create(_id, "Image of a simple geometric design consisting of two yellow squares and one blue square. " + + "The blue square is placed at a 45-degree angle, positioned centrally below the two yellow squares, creating a symmetrical arrangement. " + + "Each square is connected by what appears to be black lines or sticks, suggesting they may represent nodes or elements in a network or structure. " + + "The background is white, which contrasts with the bright colors of the squares.", 1024, 1024, new ReadOnlyMemory<byte>([0x01, 0x02, 0x03, 0x04])); + context.TextImages.Add(textImage); + await context.SaveChangesAsync(CancellationToken.None); + } + + var validator = new DeleteTextImageCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + try + { + var handler = new DeleteTextImageCommandHandler(context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + } +} diff --git a/src/Tests.Specs.Integration/Image/GetTextImageQuery.feature b/src/Tests.Specs.Integration/Image/GetTextImageQuery.feature new file mode 100644 index 0000000..73ad98a --- /dev/null +++ b/src/Tests.Specs.Integration/Image/GetTextImageQuery.feature @@ -0,0 +1,20 @@ +@getTextImageQuery +Feature: Get Text Image Query +As a actor +When I select an existing text image +I can see the text image responses + +Scenario: Get text image + Given I have a definition "<def>" + And I have a text image id "<id>" + And I the text image exists "<textPromptExists>" + When I get a text image + Then The response is "<response>" + And If the response has validation issues I see the "<responseErrors>" in the response + And If the response is successful the response has a Key + +Examples: + | def | response | responseErrors | id | textPromptExists | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | + | bad request: empty id | BadRequest | Id | 00000000-0000-0000-0000-000000000000 | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Image/GetTextImageQuery.feature.cs b/src/Tests.Specs.Integration/Image/GetTextImageQuery.feature.cs new file mode 100644 index 0000000..0743499 --- /dev/null +++ b/src/Tests.Specs.Integration/Image/GetTextImageQuery.feature.cs @@ -0,0 +1,183 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Image +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetTextImageQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getTextImageQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Image", "Get Text Image Query", "As a actor\r\nWhen I select an existing text image\r\nI can see the text image respon" + + "ses", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetTextImageQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Image/GetTextImageQuery.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get text image")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get text image")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Text Image Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getTextImageQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="GetTextImage(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="GetTextImage(not found,NotFound,,048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty id", "BadRequest", "Id", "00000000-0000-0000-0000-000000000000", "false", "2", null, DisplayName="GetTextImage(bad request: empty id,BadRequest,Id,00000000-0000-0000-0000-00000000" + + "0000,false,2)")] + public async global::System.Threading.Tasks.Task GetTextImage(string def, string response, string responseErrors, string id, string textPromptExists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("textPromptExists", textPromptExists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get text image", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a text image id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I the text image exists \"{0}\"", textPromptExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I get a text image", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync("If the response is successful the response has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Image/GetTextImageQueryStepDefinitions.cs b/src/Tests.Specs.Integration/Image/GetTextImageQueryStepDefinitions.cs new file mode 100644 index 0000000..fd604bd --- /dev/null +++ b/src/Tests.Specs.Integration/Image/GetTextImageQueryStepDefinitions.cs @@ -0,0 +1,89 @@ +using Goodtocode.AgentFramework.Core.Application.Image; +using Goodtocode.AgentFramework.Core.Domain.Image; + +namespace Goodtocode.AgentFramework.Specs.Integration.Image; + +[Binding] +[Scope(Tag = "getTextImageQuery")] +public class GetTextImageQueryStepDefinitions : TestBase +{ + private Guid _id; + private bool _exists; + private TextImageDto? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"I have a text image id ""([^""]*)""")] + public void GivenIHaveATextImageKey(string textPromptKey) + { + if (string.IsNullOrWhiteSpace(textPromptKey)) return; + Guid.TryParse(textPromptKey, out _id).ShouldBeTrue(); + } + + [Given(@"I the text image exists ""([^""]*)""")] + public void GivenIThetextImageExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I get a text image")] + public async Task WhenIGetATextImage() + { + if (_exists) + { + var textImage = TextImageEntity.Create(_id, "Image of a simple geometric design consisting of two yellow squares and one blue square. " + + "The blue square is placed at a 45-degree angle, positioned centrally below the two yellow squares, creating a symmetrical arrangement. " + + "Each square is connected by what appears to be black lines or sticks, suggesting they may represent nodes or elements in a network or structure. " + + "The background is white, which contrasts with the bright colors of the squares.", + 1024, + 1024, + new ReadOnlyMemory<byte>([0x01, 0x02, 0x03, 0x04])); + context.TextImages.Add(textImage); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetTextImageQuery() + { + Id = _id + }; + + var validator = new GetTextImageQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetTextImageQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"If the response is successful the response has a Key")] + public void ThenIfTheResponseIsSuccessfulTheResponseHasAKey() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Id.ShouldNotBeEmpty(); + } +} diff --git a/src/Tests.Specs.Integration/Image/GetTextImagesPaginatedQuery.feature b/src/Tests.Specs.Integration/Image/GetTextImagesPaginatedQuery.feature new file mode 100644 index 0000000..79fd22d --- /dev/null +++ b/src/Tests.Specs.Integration/Image/GetTextImagesPaginatedQuery.feature @@ -0,0 +1,33 @@ +@getTextImagesPaginatedQuery +Feature: Get Text Image Paginated Query +As an owner of text image +When I get text image all or by date range +I can get a paginated collection of text image + +Scenario: Get text image paginated + Given I have a definition "<def>" + And Text Image exist "<exist>" + And I have a start date "<startDate>" + And I have a end date "<endDate>" + And text image within the date range exists "<textPromptsResultExists>" + And I have a page number "<pageNumber>" + And I have a page size "<pageSize>" + When I get the text image paginated + Then The response is "<response>" + And If the response has validation issues I see the "<responseErrors>" in the response + And The response has a collection of text image + And Each text image has a Key + And Each text image has a Date greater than start date + And Each text image has a Date less than end date + And The response has a Page Number + And The response has a Total Pages + And The response has a Total Count + + +Examples: + | def | response | responseErrors | startDate | endDate | exist | textPromptsResultExists | pageNumber | pageSize | + | success no date range | Success | | | | true | true | 1 | 10 | + | success with date range | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | true | 1 | 10 | + | success empty results | Success | | | | false | false | 1 | 10 | + | bad request page number zero | BadRequest | PageNumber | | | false | false | 0 | 10 | + | bad request page size zero | BadRequest | PageSize | | | false | false | 1 | 0 | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Image/GetTextImagesPaginatedQuery.feature.cs b/src/Tests.Specs.Integration/Image/GetTextImagesPaginatedQuery.feature.cs new file mode 100644 index 0000000..2dcac7a --- /dev/null +++ b/src/Tests.Specs.Integration/Image/GetTextImagesPaginatedQuery.feature.cs @@ -0,0 +1,221 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Image +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetTextImagePaginatedQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getTextImagesPaginatedQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Image", "Get Text Image Paginated Query", "As an owner of text image\r\nWhen I get text image all or by date range\r\nI can get " + + "a paginated collection of text image", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetTextImagesPaginatedQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Image/GetTextImagesPaginatedQuery.feature.ndjson", 7); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get text image paginated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get text image paginated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Text Image Paginated Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getTextImagesPaginatedQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success no date range", "Success", "", "", "", "true", "true", "1", "10", "0", null, DisplayName="GetTextImagePaginated(success no date range,Success,,,,true,true,1,10,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", "10", "1", null, DisplayName="GetTextImagePaginated(success with date range,Success,,2024-06-01T11:21:00Z,2034-" + + "06-03T11:21:00Z,true,true,1,10,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success empty results", "Success", "", "", "", "false", "false", "1", "10", "2", null, DisplayName="GetTextImagePaginated(success empty results,Success,,,,false,false,1,10,2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request page number zero", "BadRequest", "PageNumber", "", "", "false", "false", "0", "10", "3", null, DisplayName="GetTextImagePaginated(bad request page number zero,BadRequest,PageNumber,,,false," + + "false,0,10,3)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request page size zero", "BadRequest", "PageSize", "", "", "false", "false", "1", "0", "4", null, DisplayName="GetTextImagePaginated(bad request page size zero,BadRequest,PageSize,,,false,fals" + + "e,1,0,4)")] + public async global::System.Threading.Tasks.Task GetTextImagePaginated(string def, string response, string responseErrors, string startDate, string endDate, string exist, string textPromptsResultExists, string pageNumber, string pageSize, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("startDate", startDate); + argumentsOfScenario.Add("endDate", endDate); + argumentsOfScenario.Add("exist", exist); + argumentsOfScenario.Add("textPromptsResultExists", textPromptsResultExists); + argumentsOfScenario.Add("pageNumber", pageNumber); + argumentsOfScenario.Add("pageSize", pageSize); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get text image paginated", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("Text Image exist \"{0}\"", exist), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a start date \"{0}\"", startDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a end date \"{0}\"", endDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("text image within the date range exists \"{0}\"", textPromptsResultExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("I have a page number \"{0}\"", pageNumber), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("I have a page size \"{0}\"", pageSize), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 15 + await testRunner.WhenAsync("I get the text image paginated", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 16 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 17 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 18 + await testRunner.AndAsync("The response has a collection of text image", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 19 + await testRunner.AndAsync("Each text image has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 20 + await testRunner.AndAsync("Each text image has a Date greater than start date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 21 + await testRunner.AndAsync("Each text image has a Date less than end date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 22 + await testRunner.AndAsync("The response has a Page Number", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 23 + await testRunner.AndAsync("The response has a Total Pages", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 24 + await testRunner.AndAsync("The response has a Total Count", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Image/GetTextImagesPaginatedQueryStepDefinitions.cs b/src/Tests.Specs.Integration/Image/GetTextImagesPaginatedQueryStepDefinitions.cs new file mode 100644 index 0000000..dabcd47 --- /dev/null +++ b/src/Tests.Specs.Integration/Image/GetTextImagesPaginatedQueryStepDefinitions.cs @@ -0,0 +1,163 @@ +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Core.Application.Image; +using Goodtocode.AgentFramework.Core.Domain.Image; + +namespace Goodtocode.AgentFramework.Specs.Integration.Image +{ + [Binding] + [Scope(Tag = "getTextImagesPaginatedQuery")] + public class GetTextImagesPaginatedQueryStepDefinitions : TestBase + { + private bool _exists; + private DateTime _startDate; + private DateTime _endDate; + private bool _withinDateRangeExists; + private int _pageNumber; + private int _pageSize; + private PaginatedList<TextImageDto>? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"Text Image exist ""([^""]*)""")] + public void GivenTextImagesExist(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"I have a start date ""([^""]*)""")] + public void GivenIHaveAStartDate(string startDate) + { + if (string.IsNullOrWhiteSpace(startDate)) return; + DateTime.TryParse(startDate, out _startDate).ShouldBeTrue(); + } + + [Given(@"I have a end date ""([^""]*)""")] + public void GivenIHaveAEndDate(string endDate) + { + if (string.IsNullOrWhiteSpace(endDate)) return; + DateTime.TryParse(endDate, out _endDate).ShouldBeTrue(); + } + + [Given(@"text image within the date range exists ""([^""]*)""")] + public void GivenTextImagesWithinTheDateRangeExists(string withinDateRangeExists) + { + bool.TryParse(withinDateRangeExists, out _withinDateRangeExists).ShouldBeTrue(); + } + + [Given(@"I have a page number ""([^""]*)""")] + public void GivenIHaveAPageNumber(string pageNumber) + { + int.TryParse(pageNumber, out _pageNumber).ShouldBeTrue(); + } + + [Given(@"I have a page size ""([^""]*)""")] + public void GivenIHaveAPageSize(string pageSize) + { + int.TryParse(pageSize, out _pageSize).ShouldBeTrue(); ; + } + + [When(@"I get the text image paginated")] + public async Task WhenIGetTheTextImagesPaginated() + { + if (_exists) + { + for (int i = 0; i < 2; i++) + { + var textImage = TextImageEntity.Create(Guid.NewGuid(), "A Circle", 1024, 1024, new ReadOnlyMemory<byte>([0x01, 0x02, 0x03, 0x04])); + context.TextImages.Add(textImage); + } + ; + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetTextImagesPaginatedQuery() + { + PageNumber = _pageNumber, + PageSize = _pageSize, + StartDate = _startDate == default ? null : _startDate, + EndDate = _endDate == default ? null : _endDate + }; + + var validator = new GetTextImagesPaginatedQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetTextImagesPaginatedQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"The response has a collection of text image")] + public void ThenTheResponseHasACollectionOfTextImages() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalCount.ShouldBe(_withinDateRangeExists == false ? 0 : _response.TotalCount); + } + + [Then(@"Each text image has a Key")] + public void ThenEachTextImageHasAKey() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Items.FirstOrDefault(x => x.Id == default).ShouldBeNull(); + } + + [Then(@"Each text image has a Date greater than start date")] + public void ThenEachTextImageHasADateGreaterThanStartDate() + { + if (responseType == CommandResponseType.Successful && _withinDateRangeExists) + _response?.Items.FirstOrDefault(x => _startDate == default || x.Timestamp > _startDate).ShouldNotBeNull(); + } + + [Then(@"Each text image has a Date less than end date")] + public void ThenEachTextImageHasADateLessThanEndDate() + { + if (responseType == CommandResponseType.Successful && _withinDateRangeExists) + _response?.Items.FirstOrDefault(x => _endDate == default || x.Timestamp < _endDate).ShouldNotBeNull(); + } + + [Then(@"The response has a Page Number")] + public void ThenTheResponseHasAPageNumber() + { + if (responseType != CommandResponseType.Successful) return; + _response?.PageNumber.Should(); + } + + [Then(@"The response has a Total Pages")] + public void ThenTheResponseHasATotalPages() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalPages.Should(); + } + + [Then(@"The response has a Total Count")] + public void ThenTheResponseHasATotalCount() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalCount.Should(); + } + } +} diff --git a/src/Tests.Specs.Integration/Image/GetTextImagesQuery.feature b/src/Tests.Specs.Integration/Image/GetTextImagesQuery.feature new file mode 100644 index 0000000..f710d70 --- /dev/null +++ b/src/Tests.Specs.Integration/Image/GetTextImagesQuery.feature @@ -0,0 +1,26 @@ +@getTextImagesQuery +Feature: Get Text Images Query +As a session owner +When I query text image optionally by date range +I get all sessions that fit the date range + +Scenario: Get text images + Given I have a definition "<def>" + And Text Image exist "<exist>" + And text image within the date range exists "<textPromptsResultExists>" + And I have a start date "<startDate>" + And I have a end date "<endDate>" + When I get the text image + Then The response is "<response>" + And If the response has validation issues I see the "<responseErrors>" in the response + And The response has a collection of text image + And Each text image has a Key + And Each text image has a Date greater than start date + And Each text image has a Date less than end date + +Examples: + | def | response | responseErrors | startDate | endDate | exist | textPromptsResultExists | + | success no date range | Success | | | | true | true | + | success with date range | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | true | + | success filtered results | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | false | + | success empty results | Success | | | | false | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/Image/GetTextImagesQuery.feature.cs b/src/Tests.Specs.Integration/Image/GetTextImagesQuery.feature.cs new file mode 100644 index 0000000..d5b49b3 --- /dev/null +++ b/src/Tests.Specs.Integration/Image/GetTextImagesQuery.feature.cs @@ -0,0 +1,202 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.Image +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetTextImagesQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getTextImagesQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Image", "Get Text Images Query", "As a session owner\r\nWhen I query text image optionally by date range\r\nI get all s" + + "essions that fit the date range", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetTextImagesQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Image/GetTextImagesQuery.feature.ndjson", 6); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get text images")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get text images")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Text Images Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getTextImagesQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success no date range", "Success", "", "", "", "true", "true", "0", null, DisplayName="GetTextImages(success no date range,Success,,,,true,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", null, DisplayName="GetTextImages(success with date range,Success,,2024-06-01T11:21:00Z,2034-06-03T11" + + ":21:00Z,true,true,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success filtered results", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "false", "2", null, DisplayName="GetTextImages(success filtered results,Success,,2024-06-01T11:21:00Z,2034-06-03T1" + + "1:21:00Z,true,false,2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success empty results", "Success", "", "", "", "false", "false", "3", null, DisplayName="GetTextImages(success empty results,Success,,,,false,false,3)")] + public async global::System.Threading.Tasks.Task GetTextImages(string def, string response, string responseErrors, string startDate, string endDate, string exist, string textPromptsResultExists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("startDate", startDate); + argumentsOfScenario.Add("endDate", endDate); + argumentsOfScenario.Add("exist", exist); + argumentsOfScenario.Add("textPromptsResultExists", textPromptsResultExists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get text images", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("Text Image exist \"{0}\"", exist), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("text image within the date range exists \"{0}\"", textPromptsResultExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a start date \"{0}\"", startDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("I have a end date \"{0}\"", endDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.WhenAsync("I get the text image", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 14 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 15 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 16 + await testRunner.AndAsync("The response has a collection of text image", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 17 + await testRunner.AndAsync("Each text image has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 18 + await testRunner.AndAsync("Each text image has a Date greater than start date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 19 + await testRunner.AndAsync("Each text image has a Date less than end date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/Image/GetTextImagesQueryStepDefinitions.cs b/src/Tests.Specs.Integration/Image/GetTextImagesQueryStepDefinitions.cs new file mode 100644 index 0000000..5d5bab2 --- /dev/null +++ b/src/Tests.Specs.Integration/Image/GetTextImagesQueryStepDefinitions.cs @@ -0,0 +1,123 @@ +using Goodtocode.AgentFramework.Core.Application.Image; +using Goodtocode.AgentFramework.Core.Domain.Image; + +namespace Goodtocode.AgentFramework.Specs.Integration.Image; + +[Binding] +[Scope(Tag = "getTextImagesQuery")] +public class GetTextImagesQueryStepDefinitions : TestBase +{ + private bool _exists; + private bool _withinDateRangeExists; + private DateTime _endDate; + private DateTime _startDate; + private ICollection<TextImageDto>? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"Text Image exist ""([^""]*)""")] + public void GivenTextImagesExist(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"text image within the date range exists ""([^""]*)""")] + public void GivenTextImagesWithinTheDateRangeExists(string withinDateRangeExists) + { + bool.TryParse(withinDateRangeExists, out _withinDateRangeExists).ShouldBeTrue(); + } + + [Given(@"I have a start date ""([^""]*)""")] + public void GivenIHaveAStartDate(string startDate) + { + if (string.IsNullOrWhiteSpace(startDate)) return; + DateTime.TryParse(startDate, out _startDate).ShouldBeTrue(); + _startDate = DateTime.UtcNow.AddMinutes(_withinDateRangeExists ? -1 : 1); //Handle for desired not-found scenarios + } + + [Given(@"I have a end date ""([^""]*)""")] + public void GivenIHaveAEndDate(string endDate) + { + if (string.IsNullOrWhiteSpace(endDate)) return; + DateTime.TryParse(endDate, out _endDate).ShouldBeTrue(); + } + + [When(@"I get the text image")] + public async Task WhenIGetTheTextImages() + { + if (_exists) + { + for (int i = 0; i < 2; i++) + { + var textImage = TextImageEntity.Create(Guid.NewGuid(), "A circle", 1024, 1024, new ReadOnlyMemory<byte>([0x01, 0x02, 0x03, 0x04])); + context.TextImages.Add(textImage); + } + ; + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetTextImagesQuery() + { + StartDate = _startDate == default ? null : _startDate, + EndDate = _endDate == default ? null : _endDate + }; + + var validator = new GetTextImagesQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetTextImagesQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"The response has a collection of text image")] + public void ThenTheResponseHasACollectionOfTextImages() + { + _response?.Count.ShouldBe(_withinDateRangeExists == false ? 0 : _response.Count); + } + + [Then(@"Each text image has a Key")] + public void ThenEachTextImageHasAKey() + { + _response?.FirstOrDefault(x => x.Id == default).ShouldBeNull(); + } + + [Then(@"Each text image has a Date greater than start date")] + public void ThenEachTextImageHasADateGreaterThanStartDate() + { + if (_withinDateRangeExists) + _response?.FirstOrDefault(x => _startDate == default || x.Timestamp > _startDate).ShouldNotBeNull(); + } + + [Then(@"Each text image has a Date less than end date")] + public void ThenEachTextImageHasADateLessThanEndDate() + { + if (_withinDateRangeExists) + _response?.FirstOrDefault(x => _endDate == default || x.Timestamp < _endDate).ShouldNotBeNull(); + } +} diff --git a/src/Tests.Specs.Integration/TestBase.cs b/src/Tests.Specs.Integration/TestBase.cs new file mode 100644 index 0000000..6c07219 --- /dev/null +++ b/src/Tests.Specs.Integration/TestBase.cs @@ -0,0 +1,157 @@ +using Goodtocode.AgentFramework.Core.Application.Abstractions; +using Goodtocode.AgentFramework.Core.Application.Common.Exceptions; +using Goodtocode.AgentFramework.Core.Domain.Auth; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Options; +using Goodtocode.AgentFramework.Infrastructure.AgentFramework.Plugins; +using Goodtocode.AgentFramework.Infrastructure.SqlServer.Persistence; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel; +using System.Reflection; + +namespace Goodtocode.AgentFramework.Specs.Integration; + +public abstract class TestBase : IDisposable +{ + public enum CommandResponseType + { + Successful, + BadRequest, + NotFound, + Conflict, + Error + } + + internal string def = string.Empty; + internal IDictionary<string, string[]> commandErrors = new ConcurrentDictionary<string, string[]>(); + internal Exception? exception; + internal CommandResponseType responseType; + internal ValidationResult validationResponse = new(); + internal AgentFrameworkContext context; + internal IConfiguration configuration; + internal Kernel kernel = new(); + internal OpenAIOptions optionsOpenAi = new(); + internal UserEntity userInfo = UserEntity.Create(firstName: "John", lastName: "Doe", email: "john.doe@goodtocode.com", + ownerId: Guid.NewGuid(), tenantId: Guid.NewGuid(), roles: ["Admin"]); + + public TestBase() + { + context = new AgentFrameworkContext(new DbContextOptionsBuilder<AgentFrameworkContext>() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + + var executingType = Assembly.GetExecutingAssembly().GetTypes() + .FirstOrDefault(x => x.Name == "AutoGeneratedProgram") ?? typeof(TestBase); + configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.test.json", optional: true, reloadOnChange: true) + .AddUserSecrets(executingType.Assembly, optional: true) + .AddEnvironmentVariables() + .Build(); + + // The SK Plugins currently rely on GetRequiredService<IAgentFrameworkContext>(), so we need to register it as a scoped service. + // This is a workaround to allow the plugins to be registered in the DI container as Singleton which SK memory wants, despite an EF dependency which wants Scoped. + var services = new ServiceCollection(); + services.AddDbContext<AgentFrameworkContext>(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddScoped<IAgentFrameworkContext, AgentFrameworkContext>(); + var provider = services.BuildServiceProvider(); + configuration.GetSection(nameof(OpenAI)).Bind(optionsOpenAi); + var builder = Kernel.CreateBuilder(); +#pragma warning disable SKEXP0010 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + builder.Services + .AddOpenAIChatCompletion(modelId: optionsOpenAi.ChatCompletionModelId, apiKey: optionsOpenAi.ApiKey) + .AddOpenAIAudioToText(modelId: optionsOpenAi.AudioModelId, apiKey: optionsOpenAi.ApiKey) + .AddOpenAITextToAudio(modelId: optionsOpenAi.AudioModelId, apiKey: optionsOpenAi.ApiKey) + .AddOpenAITextToImage(modelId: optionsOpenAi.ImageModelId, apiKey: optionsOpenAi.ApiKey); +#pragma warning restore SKEXP0010 + builder.Services.AddLogging(logging => + { + logging.SetMinimumLevel(LogLevel.Debug); + }); + kernel = builder.Build(); + + var serviceProvider = builder.Services.BuildServiceProvider(); + var authorsPlugin = new ActorsPlugin(serviceProvider); + var chatSessionsPlugin = new ChatSessionsPlugin(serviceProvider); + var chatMessagesPlugin = new ChatMessagesPlugin(serviceProvider); + + kernel.ImportPluginFromObject(authorsPlugin, nameof(ActorsPlugin)); + kernel.ImportPluginFromObject(chatSessionsPlugin, nameof(ChatSessionsPlugin)); + kernel.ImportPluginFromObject(chatMessagesPlugin, nameof(ChatMessagesPlugin)); + } + + internal CommandResponseType HandleAssignResponseType + (Exception e) + { + exception = e; + switch (e) + { + case CustomValidationException validationException: + commandErrors = validationException.Errors; + responseType = CommandResponseType.BadRequest; + break; + case CustomNotFoundException: + responseType = CommandResponseType.NotFound; + break; + case CustomConflictException: + responseType = CommandResponseType.Conflict; + break; + default: + responseType = CommandResponseType.Error; + break; + } + + return responseType; + } + + internal void HandleHasResponseType(string response) + { + switch (response) + { + case "Success": + using (new AssertionScope()) + { + responseType.ShouldBe(CommandResponseType.Successful); + exception.ShouldBeNull($"An exception was thrown: {exception?.Message}. Inner exception: {exception?.InnerException?.Message}"); + } + break; + case "BadRequest": + responseType.ShouldBe(CommandResponseType.BadRequest); + break; + case "NotFound": + responseType.ShouldBe(CommandResponseType.NotFound); + break; + } + } + + internal void HandleExpectedValidationErrorsAssertions(string expectedErrors) + { + var def = this.def; + + if (string.IsNullOrWhiteSpace(expectedErrors)) return; + + var expectedErrorsCollection = expectedErrors.Split(","); + + foreach (var field in expectedErrorsCollection) + { + var hasCommandValidatorErrors = validationResponse.Errors.Any(x => x.PropertyName == field.Trim()); + var hasCommandErrors = commandErrors.Any(x => x.Key == field.Trim()); + var hasErrorMatch = hasCommandErrors || hasCommandValidatorErrors; + hasErrorMatch.ShouldBeTrue(); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + context?.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/Tests.Specs.Integration/TestUserInfo.cs b/src/Tests.Specs.Integration/TestUserInfo.cs new file mode 100644 index 0000000..4064f2a --- /dev/null +++ b/src/Tests.Specs.Integration/TestUserInfo.cs @@ -0,0 +1,18 @@ +using Goodtocode.AgentFramework.Core.Domain.Auth; + +namespace Goodtocode.AgentFramework.Specs.Integration; + +public class TestUserInfo() : IUserEntity +{ + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _tenantId = Guid.NewGuid(); + public Guid OwnerId => _userId; + public Guid TenantId => _tenantId; + public string FirstName => "John"; + public string LastName => "Tester"; + public string Email => "John.Tester@goodtocode.com"; + public IEnumerable<string> Roles => ["Admin"]; + public bool CanView => true; + public bool CanEdit => true; + public bool CanDelete => true; +} diff --git a/src/Tests.Specs.Integration/Tests.Specs.Integration.csproj b/src/Tests.Specs.Integration/Tests.Specs.Integration.csproj new file mode 100644 index 0000000..26af866 --- /dev/null +++ b/src/Tests.Specs.Integration/Tests.Specs.Integration.csproj @@ -0,0 +1,41 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <RootNamespace>Goodtocode.AgentFramework.Specs.Integration</RootNamespace> + <AssemblyName>Goodtocode.AgentFramework.Specs.Integration</AssemblyName> + <Version>1.0.0</Version> + <TargetFramework>net10.0</TargetFramework> + <IsPackable>false</IsPackable> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <UserSecretsId>998fefad-d248-4bad-9d33-8a060711dd88</UserSecretsId> + </PropertyGroup> + + <ItemGroup> + <None Remove="Actor\GetActorQueryStepDefinitions.cs~RFd5e051.TMP" /> + <None Remove="appsettings.test.json" /> + </ItemGroup> + + <ItemGroup> + <Content Include="appsettings.test.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Goodtocode.Assertion" Version="1.1.30" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" /> + <PackageReference Include="Moq" Version="4.20.72" /> + <PackageReference Include="MSTest.TestAdapter" Version="4.0.2" /> + <PackageReference Include="MSTest.TestFramework" Version="4.0.2" /> + <PackageReference Include="Reqnroll.MsTest" Version="3.3.2" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\Core.Application\Core.Application.csproj" /> + <ProjectReference Include="..\Infrastructure.AgentFramework\Infrastructure.AgentFramework.csproj" /> + <ProjectReference Include="..\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj" /> + </ItemGroup> + +</Project> \ No newline at end of file diff --git a/src/Tests.Specs.Integration/TextGeneration/CreateTextPromptCommand.feature b/src/Tests.Specs.Integration/TextGeneration/CreateTextPromptCommand.feature new file mode 100644 index 0000000..2777f40 --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/CreateTextPromptCommand.feature @@ -0,0 +1,20 @@ +@createTextPromptCommand +Feature: Create Text Prompt Command +As a actor +When I start a new text prompt and enter an initial prompt +Then I should see the text prompt created with the initial response + +Scenario: Create Text Prompt + Given I have a def "<def>" + And I have a initial prompt "<prompt>" + And I have a text prompt id "<id>" + And The text prompt exists "<TextPromptExists>" + When I create a text prompt with the prompt + Then I see the text prompt created with the initial response "<response>" + And if the response has validation issues I see the "<responseErrors>" in the response + +Examples: + | def | response | responseErrors | id | TextPromptExists | prompt | + | success | Success | | 00000000-0000-0000-0000-000000000000 | false | Tell me a bedtime story. | + | bad request: empty propmt | BadRequest | Prompt | 00000000-0000-0000-0000-000000000000 | false | | + | already exists | Error | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | Tell me a bedtime story. | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/TextGeneration/CreateTextPromptCommand.feature.cs b/src/Tests.Specs.Integration/TextGeneration/CreateTextPromptCommand.feature.cs new file mode 100644 index 0000000..5721e37 --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/CreateTextPromptCommand.feature.cs @@ -0,0 +1,186 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.TextGeneration +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class CreateTextPromptCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "createTextPromptCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "TextGeneration", "Create Text Prompt Command", "As a actor\r\nWhen I start a new text prompt and enter an initial prompt\r\nThen I sh" + + "ould see the text prompt created with the initial response", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "CreateTextPromptCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("TextGeneration/CreateTextPromptCommand.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Create Text Prompt")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Create Text Prompt")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Create Text Prompt Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("createTextPromptCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "00000000-0000-0000-0000-000000000000", "false", "Tell me a bedtime story.", "0", null, DisplayName="CreateTextPrompt(success,Success,,00000000-0000-0000-0000-000000000000,false,Tell" + + " me a bedtime story.,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty propmt", "BadRequest", "Prompt", "00000000-0000-0000-0000-000000000000", "false", "", "1", null, DisplayName="CreateTextPrompt(bad request: empty propmt,BadRequest,Prompt,00000000-0000-0000-0" + + "000-000000000000,false,,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("already exists", "Error", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "Tell me a bedtime story.", "2", null, DisplayName="CreateTextPrompt(already exists,Error,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true," + + "Tell me a bedtime story.,2)")] + public async global::System.Threading.Tasks.Task CreateTextPrompt(string def, string response, string responseErrors, string id, string textPromptExists, string prompt, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("TextPromptExists", textPromptExists); + argumentsOfScenario.Add("prompt", prompt); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Create Text Prompt", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a initial prompt \"{0}\"", prompt), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a text prompt id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("The text prompt exists \"{0}\"", textPromptExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.WhenAsync("I create a text prompt with the prompt", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 13 + await testRunner.ThenAsync(string.Format("I see the text prompt created with the initial response \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("if the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/TextGeneration/CreateTextPromptCommandStepDefinitions.cs b/src/Tests.Specs.Integration/TextGeneration/CreateTextPromptCommandStepDefinitions.cs new file mode 100644 index 0000000..bafb525 --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/CreateTextPromptCommandStepDefinitions.cs @@ -0,0 +1,86 @@ +using Goodtocode.AgentFramework.Core.Application.TextGeneration; +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +namespace Goodtocode.AgentFramework.Specs.Integration.TextGeneration; + +[Binding] +[Scope(Tag = "createTextPromptCommand")] +public class CreateTextPromptCommandStepDefinitions : TestBase +{ + private string _prompt = string.Empty; + private Guid _id; + private bool _exists; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a initial prompt ""([^""]*)""")] + public void GivenIHaveAInitialprompt(string prompt) + { + _prompt = prompt; + } + + [Given(@"I have a text prompt id ""([^""]*)""")] + public void GivenIHaveATextPromptKey(string id) + { + _id = Guid.Parse(id); + } + + [Given(@"The text prompt exists ""([^""]*)""")] + public void GivenTheTextPromptExists(string exists) + { + _exists = bool.Parse(exists); + } + + [When(@"I create a text prompt with the prompt")] + public async Task WhenICreateATextPromptWithTheprompt() + { + if (_exists) + { + var textPrompt = TextPromptEntity.Create(_id, Guid.Empty, _prompt); + context.TextResponses.Add(TextResponseEntity.Create(Guid.Empty, textPrompt.Id, "Fantastic story here.")); + context.TextPrompts.Add(textPrompt); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new CreateTextPromptCommand() + { + Id = _id, + Prompt = _prompt + }; + + var validator = new CreateTextPromptCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + { + try + { + var handler = new CreateTextPromptCommandHandler(kernel, context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"I see the text prompt created with the initial response ""([^""]*)""")] + public void ThenISeeTheTextPromptCreatedWithTheInitialResponse(string response) + { + HandleHasResponseType(response); + } + + [Then(@"if the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } +} diff --git a/src/Tests.Specs.Integration/TextGeneration/DeleteTextPromptCommand.feature b/src/Tests.Specs.Integration/TextGeneration/DeleteTextPromptCommand.feature new file mode 100644 index 0000000..41de335 --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/DeleteTextPromptCommand.feature @@ -0,0 +1,19 @@ +@deleteTextPromptCommand +Feature: Delete Text Prompt Command +As a text prompt owner +When I select a text prompt +I can delete the text prompt + +Scenario: Delete Text Prompt + Given I have a def "<def>" + And I have a text prompt id"<id>" + And The text prompt exists "<exists>" + When I delete the text prompt + Then The response is "<response>" + And If the response has validation issues I see the "<responseErrors>" in the response + +Examples: + | def | response | responseErrors | id | exists | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | + | bad request: empty id | BadRequest | Id | 00000000-0000-0000-0000-000000000000 | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/TextGeneration/DeleteTextPromptCommand.feature.cs b/src/Tests.Specs.Integration/TextGeneration/DeleteTextPromptCommand.feature.cs new file mode 100644 index 0000000..92840cf --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/DeleteTextPromptCommand.feature.cs @@ -0,0 +1,181 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.TextGeneration +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class DeleteTextPromptCommandFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "deleteTextPromptCommand"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "TextGeneration", "Delete Text Prompt Command", "As a text prompt owner\r\nWhen I select a text prompt\r\nI can delete the text prompt" + + "", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "DeleteTextPromptCommand.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("TextGeneration/DeleteTextPromptCommand.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Delete Text Prompt")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Delete Text Prompt")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Delete Text Prompt Command")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("deleteTextPromptCommand")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="DeleteTextPrompt(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="DeleteTextPrompt(not found,NotFound,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false,1" + + ")")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty id", "BadRequest", "Id", "00000000-0000-0000-0000-000000000000", "false", "2", null, DisplayName="DeleteTextPrompt(bad request: empty id,BadRequest,Id,00000000-0000-0000-0000-0000" + + "00000000,false,2)")] + public async global::System.Threading.Tasks.Task DeleteTextPrompt(string def, string response, string responseErrors, string id, string exists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("exists", exists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Delete Text Prompt", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a def \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a text prompt id\"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("The text prompt exists \"{0}\"", exists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I delete the text prompt", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/TextGeneration/DeleteTextPromptCommandStepDefinitions.cs b/src/Tests.Specs.Integration/TextGeneration/DeleteTextPromptCommandStepDefinitions.cs new file mode 100644 index 0000000..3ad7f00 --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/DeleteTextPromptCommandStepDefinitions.cs @@ -0,0 +1,77 @@ +using Goodtocode.AgentFramework.Core.Application.TextGeneration; +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +namespace Goodtocode.AgentFramework.Specs.Integration.TextGeneration +{ + [Binding] + [Scope(Tag = "deleteTextPromptCommand")] + public class DeleteTextPromptCommandStepDefinitions : TestBase + { + private Guid _id; + private bool _exists; + + [Given(@"I have a def ""([^""]*)""")] + public void GivenIHaveADef(string def) + { + base.def = def; + } + + [Given(@"I have a text prompt id""([^""]*)""")] + public void GivenIHaveATextPromptKey(string id) + { + Guid.TryParse(id, out _id).ShouldBeTrue(); + } + + [Given(@"The text prompt exists ""([^""]*)""")] + public void GivenTheTextPromptExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I delete the text prompt")] + public async Task WhenIDeleteTheTextPrompt() + { + var request = new DeleteTextPromptCommand() + { + Id = _id + }; + + if (_exists) + { + var textPrompt = TextPromptEntity.Create(_id, Guid.Empty, "Tell me a bedtime story"); + context.TextResponses.Add(TextResponseEntity.Create(Guid.Empty, textPrompt.Id, "Once upon a time...")); + context.TextPrompts.Add(textPrompt); + await context.SaveChangesAsync(CancellationToken.None); + } + + var validator = new DeleteTextPromptCommandValidator(); + validationResponse = await validator.ValidateAsync(request); + + if (validationResponse.IsValid) + try + { + var handler = new DeleteTextPromptCommandHandler(context); + await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + } +} diff --git a/src/Tests.Specs.Integration/TextGeneration/GetTextPromptQuery.feature b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptQuery.feature new file mode 100644 index 0000000..f6cd0a2 --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptQuery.feature @@ -0,0 +1,20 @@ +@getTextPromptQuery +Feature: Get Text Prompt Query +As a actor +When I select an existing text prompt +I can see the text prompt responses + +Scenario: Get text prompt + Given I have a definition "<def>" + And I have a text prompt id "<id>" + And I the text prompt exists "<textPromptExists>" + When I get a text prompt + Then The response is "<response>" + And If the response has validation issues I see the "<responseErrors>" in the response + And If the response is successful the response has a Key + +Examples: + | def | response | responseErrors | id | textPromptExists | + | success | Success | | 038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | true | + | not found | NotFound | | 048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9 | false | + | bad request: empty id | BadRequest | Id | 00000000-0000-0000-0000-000000000000 | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/TextGeneration/GetTextPromptQuery.feature.cs b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptQuery.feature.cs new file mode 100644 index 0000000..18f530d --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptQuery.feature.cs @@ -0,0 +1,183 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.TextGeneration +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetTextPromptQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getTextPromptQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "TextGeneration", "Get Text Prompt Query", "As a actor\r\nWhen I select an existing text prompt\r\nI can see the text prompt resp" + + "onses", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetTextPromptQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("TextGeneration/GetTextPromptQuery.feature.ndjson", 5); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get text prompt")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get text prompt")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Text Prompt Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getTextPromptQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success", "Success", "", "038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "true", "0", null, DisplayName="GetTextPrompt(success,Success,,038d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("not found", "NotFound", "", "048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9", "false", "1", null, DisplayName="GetTextPrompt(not found,NotFound,,048d8e7f-f18f-4a8e-8b3c-3b6a6889fed9,false,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request: empty id", "BadRequest", "Id", "00000000-0000-0000-0000-000000000000", "false", "2", null, DisplayName="GetTextPrompt(bad request: empty id,BadRequest,Id,00000000-0000-0000-0000-0000000" + + "00000,false,2)")] + public async global::System.Threading.Tasks.Task GetTextPrompt(string def, string response, string responseErrors, string id, string textPromptExists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("id", id); + argumentsOfScenario.Add("textPromptExists", textPromptExists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get text prompt", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("I have a text prompt id \"{0}\"", id), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I the text prompt exists \"{0}\"", textPromptExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.WhenAsync("I get a text prompt", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 12 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync("If the response is successful the response has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/TextGeneration/GetTextPromptQueryStepDefinitions.cs b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptQueryStepDefinitions.cs new file mode 100644 index 0000000..187811a --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptQueryStepDefinitions.cs @@ -0,0 +1,84 @@ +using Goodtocode.AgentFramework.Core.Application.TextGeneration; +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +namespace Goodtocode.AgentFramework.Specs.Integration.TextGeneration; + +[Binding] +[Scope(Tag = "getTextPromptQuery")] +public class GetTextPromptQueryStepDefinitions : TestBase +{ + private Guid _id; + private bool _exists; + private TextPromptDto? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"I have a text prompt id ""([^""]*)""")] + public void GivenIHaveATextPromptKey(string textPromptKey) + { + if (string.IsNullOrWhiteSpace(textPromptKey)) return; + Guid.TryParse(textPromptKey, out _id).ShouldBeTrue(); + } + + [Given(@"I the text prompt exists ""([^""]*)""")] + public void GivenITheTextPromptExists(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [When(@"I get a text prompt")] + public async Task WhenIGetATextPrompt() + { + if (_exists) + { + var textPrompt = TextPromptEntity.Create(_id, Guid.Empty, "Tell me a bedtime story"); + context.TextResponses.Add(TextResponseEntity.Create(Guid.Empty, textPrompt.Id, "Once upon a time...")); + context.TextPrompts.Add(textPrompt); + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetTextPromptQuery() + { + Id = _id + }; + + var validator = new GetTextPromptQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetTextPromptQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"If the response is successful the response has a Key")] + public void ThenIfTheResponseIsSuccessfulTheResponseHasAKey() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Id.ShouldNotBeEmpty(); + } +} diff --git a/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsPaginatedQuery.feature b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsPaginatedQuery.feature new file mode 100644 index 0000000..27949fa --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsPaginatedQuery.feature @@ -0,0 +1,33 @@ +@getTextPromptsPaginatedQuery +Feature: Get Text Prompt Paginated Query +As an owner of text prompt +When I get text prompt all or by date range +I can get a paginated collection of text prompt + +Scenario: Get text prompt paginated + Given I have a definition "<def>" + And Text Prompt exist "<exist>" + And I have a start date "<startDate>" + And I have a end date "<endDate>" + And text prompt within the date range exists "<textPromptsResultExists>" + And I have a page number "<pageNumber>" + And I have a page size "<pageSize>" + When I get the text prompt paginated + Then The response is "<response>" + And If the response has validation issues I see the "<responseErrors>" in the response + And The response has a collection of text prompt + And Each text prompt has a Key + And Each text prompt has a Date greater than start date + And Each text prompt has a Date less than end date + And The response has a Page Number + And The response has a Total Pages + And The response has a Total Count + + +Examples: + | def | response | responseErrors | startDate | endDate | exist | textPromptsResultExists | pageNumber | pageSize | + | success no date range | Success | | | | true | true | 1 | 10 | + | success with date range | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | true | 1 | 10 | + | success empty results | Success | | | | false | false | 1 | 10 | + | bad request page number zero | BadRequest | PageNumber | | | false | false | 0 | 10 | + | bad request page size zero | BadRequest | PageSize | | | false | false | 1 | 0 | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsPaginatedQuery.feature.cs b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsPaginatedQuery.feature.cs new file mode 100644 index 0000000..1070943 --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsPaginatedQuery.feature.cs @@ -0,0 +1,221 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.TextGeneration +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetTextPromptPaginatedQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getTextPromptsPaginatedQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "TextGeneration", "Get Text Prompt Paginated Query", "As an owner of text prompt\r\nWhen I get text prompt all or by date range\r\nI can ge" + + "t a paginated collection of text prompt", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetTextPromptsPaginatedQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("TextGeneration/GetTextPromptsPaginatedQuery.feature.ndjson", 7); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get text prompt paginated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get text prompt paginated")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Text Prompt Paginated Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getTextPromptsPaginatedQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success no date range", "Success", "", "", "", "true", "true", "1", "10", "0", null, DisplayName="GetTextPromptPaginated(success no date range,Success,,,,true,true,1,10,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", "10", "1", null, DisplayName="GetTextPromptPaginated(success with date range,Success,,2024-06-01T11:21:00Z,2034" + + "-06-03T11:21:00Z,true,true,1,10,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success empty results", "Success", "", "", "", "false", "false", "1", "10", "2", null, DisplayName="GetTextPromptPaginated(success empty results,Success,,,,false,false,1,10,2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request page number zero", "BadRequest", "PageNumber", "", "", "false", "false", "0", "10", "3", null, DisplayName="GetTextPromptPaginated(bad request page number zero,BadRequest,PageNumber,,,false" + + ",false,0,10,3)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("bad request page size zero", "BadRequest", "PageSize", "", "", "false", "false", "1", "0", "4", null, DisplayName="GetTextPromptPaginated(bad request page size zero,BadRequest,PageSize,,,false,fal" + + "se,1,0,4)")] + public async global::System.Threading.Tasks.Task GetTextPromptPaginated(string def, string response, string responseErrors, string startDate, string endDate, string exist, string textPromptsResultExists, string pageNumber, string pageSize, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("startDate", startDate); + argumentsOfScenario.Add("endDate", endDate); + argumentsOfScenario.Add("exist", exist); + argumentsOfScenario.Add("textPromptsResultExists", textPromptsResultExists); + argumentsOfScenario.Add("pageNumber", pageNumber); + argumentsOfScenario.Add("pageSize", pageSize); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get text prompt paginated", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("Text Prompt exist \"{0}\"", exist), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("I have a start date \"{0}\"", startDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a end date \"{0}\"", endDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("text prompt within the date range exists \"{0}\"", textPromptsResultExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.AndAsync(string.Format("I have a page number \"{0}\"", pageNumber), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 14 + await testRunner.AndAsync(string.Format("I have a page size \"{0}\"", pageSize), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 15 + await testRunner.WhenAsync("I get the text prompt paginated", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 16 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 17 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 18 + await testRunner.AndAsync("The response has a collection of text prompt", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 19 + await testRunner.AndAsync("Each text prompt has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 20 + await testRunner.AndAsync("Each text prompt has a Date greater than start date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 21 + await testRunner.AndAsync("Each text prompt has a Date less than end date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 22 + await testRunner.AndAsync("The response has a Page Number", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 23 + await testRunner.AndAsync("The response has a Total Pages", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 24 + await testRunner.AndAsync("The response has a Total Count", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsPaginatedQueryStepDefinitions.cs b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsPaginatedQueryStepDefinitions.cs new file mode 100644 index 0000000..a6b49f2 --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsPaginatedQueryStepDefinitions.cs @@ -0,0 +1,164 @@ +using Goodtocode.AgentFramework.Core.Application.Common.Models; +using Goodtocode.AgentFramework.Core.Application.TextGeneration; +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +namespace Goodtocode.AgentFramework.Specs.Integration.TextGeneration +{ + [Binding] + [Scope(Tag = "getTextPromptsPaginatedQuery")] + public class GetTextPromptsPaginatedQueryStepDefinitions : TestBase + { + private bool _exists; + private DateTime _startDate; + private DateTime _endDate; + private bool _withinDateRangeExists; + private int _pageNumber; + private int _pageSize; + private PaginatedList<TextPromptDto>? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"Text Prompt exist ""([^""]*)""")] + public void GivenTextPromptsExist(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"I have a start date ""([^""]*)""")] + public void GivenIHaveAStartDate(string startDate) + { + if (string.IsNullOrWhiteSpace(startDate)) return; + DateTime.TryParse(startDate, out _startDate).ShouldBeTrue(); + } + + [Given(@"I have a end date ""([^""]*)""")] + public void GivenIHaveAEndDate(string endDate) + { + if (string.IsNullOrWhiteSpace(endDate)) return; + DateTime.TryParse(endDate, out _endDate).ShouldBeTrue(); + } + + [Given(@"text prompt within the date range exists ""([^""]*)""")] + public void GivenTextPromptsWithinTheDateRangeExists(string withinDateRangeExists) + { + bool.TryParse(withinDateRangeExists, out _withinDateRangeExists).ShouldBeTrue(); + } + + [Given(@"I have a page number ""([^""]*)""")] + public void GivenIHaveAPageNumber(string pageNumber) + { + int.TryParse(pageNumber, out _pageNumber).ShouldBeTrue(); + } + + [Given(@"I have a page size ""([^""]*)""")] + public void GivenIHaveAPageSize(string pageSize) + { + int.TryParse(pageSize, out _pageSize).ShouldBeTrue(); ; + } + + [When(@"I get the text prompt paginated")] + public async Task WhenIGetTheTextPromptsPaginated() + { + if (_exists) + { + for (int i = 0; i < 2; i++) + { + var textPrompt = TextPromptEntity.Create(Guid.NewGuid(), Guid.Empty, "Tell me a bedtime story"); + context.TextResponses.Add(TextResponseEntity.Create(Guid.Empty, textPrompt.Id, "Once upon a time...")); + context.TextPrompts.Add(textPrompt); + } + ; + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetTextPromptsPaginatedQuery() + { + PageNumber = _pageNumber, + PageSize = _pageSize, + StartDate = _startDate == default ? null : _startDate, + EndDate = _endDate == default ? null : _endDate + }; + + var validator = new GetTextPromptsPaginatedQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetTextPromptsPaginatedQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"The response has a collection of text prompt")] + public void ThenTheResponseHasACollectionOfTextPrompts() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalCount.ShouldBe(_withinDateRangeExists == false ? 0 : _response.TotalCount); + } + + [Then(@"Each text prompt has a Key")] + public void ThenEachTextPromptHasAKey() + { + if (responseType != CommandResponseType.Successful) return; + _response?.Items.FirstOrDefault(x => x.Id == default).ShouldBeNull(); + } + + [Then(@"Each text prompt has a Date greater than start date")] + public void ThenEachTextPromptHasADateGreaterThanStartDate() + { + if (responseType == CommandResponseType.Successful && _withinDateRangeExists) + _response?.Items.FirstOrDefault(x => _startDate == default || x.Timestamp > _startDate).ShouldNotBeNull(); + } + + [Then(@"Each text prompt has a Date less than end date")] + public void ThenEachTextPromptHasADateLessThanEndDate() + { + if (responseType == CommandResponseType.Successful && _withinDateRangeExists) + _response?.Items.FirstOrDefault(x => _endDate == default || x.Timestamp < _endDate).ShouldNotBeNull(); + } + + [Then(@"The response has a Page Number")] + public void ThenTheResponseHasAPageNumber() + { + if (responseType != CommandResponseType.Successful) return; + _response?.PageNumber.Should(); + } + + [Then(@"The response has a Total Pages")] + public void ThenTheResponseHasATotalPages() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalPages.Should(); + } + + [Then(@"The response has a Total Count")] + public void ThenTheResponseHasATotalCount() + { + if (responseType != CommandResponseType.Successful) return; + _response?.TotalCount.Should(); + } + } +} diff --git a/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsQuery.feature b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsQuery.feature new file mode 100644 index 0000000..a9cd5cd --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsQuery.feature @@ -0,0 +1,26 @@ +@getTextPromptsQuery +Feature: Get Text Prompts Query +As a session owner +When I query text prompt optionally by date range +I get all sessions that fit the date range + +Scenario: Get text prompts + Given I have a definition "<def>" + And Text Prompt exist "<exist>" + And text prompt within the date range exists "<textPromptsResultExists>" + And I have a start date "<startDate>" + And I have a end date "<endDate>" + When I get the text prompt + Then The response is "<response>" + And If the response has validation issues I see the "<responseErrors>" in the response + And The response has a collection of text prompt + And Each text prompt has a Key + And Each text prompt has a Date greater than start date + And Each text prompt has a Date less than end date + +Examples: + | def | response | responseErrors | startDate | endDate | exist | textPromptsResultExists | + | success no date range | Success | | | | true | true | + | success with date range | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | true | + | success filtered results | Success | | 2024-06-01T11:21:00Z | 2034-06-03T11:21:00Z | true | false | + | success empty results | Success | | | | false | false | \ No newline at end of file diff --git a/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsQuery.feature.cs b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsQuery.feature.cs new file mode 100644 index 0000000..abc014c --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsQuery.feature.cs @@ -0,0 +1,202 @@ +// ------------------------------------------------------------------------------ +// <auto-generated> +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// </auto-generated> +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace Goodtocode.AgentFramework.Specs.Integration.TextGeneration +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class GetTextPromptsQueryFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = new string[] { + "getTextPromptsQuery"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "TextGeneration", "Get Text Prompts Query", "As a session owner\r\nWhen I query text prompt optionally by date range\r\nI get all " + + "sessions that fit the date range", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "GetTextPromptsQuery.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<Microsoft.VisualStudio.TestTools.UnitTesting.TestContext>(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("TextGeneration/GetTextPromptsQuery.feature.ndjson", 6); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute(callerLineNumber: 7, DisplayName="Get text prompts")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Get text prompts")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "Get Text Prompts Query")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute("getTextPromptsQuery")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success no date range", "Success", "", "", "", "true", "true", "0", null, DisplayName="GetTextPrompts(success no date range,Success,,,,true,true,0)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success with date range", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "true", "1", null, DisplayName="GetTextPrompts(success with date range,Success,,2024-06-01T11:21:00Z,2034-06-03T1" + + "1:21:00Z,true,true,1)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success filtered results", "Success", "", "2024-06-01T11:21:00Z", "2034-06-03T11:21:00Z", "true", "false", "2", null, DisplayName="GetTextPrompts(success filtered results,Success,,2024-06-01T11:21:00Z,2034-06-03T" + + "11:21:00Z,true,false,2)")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute("success empty results", "Success", "", "", "", "false", "false", "3", null, DisplayName="GetTextPrompts(success empty results,Success,,,,false,false,3)")] + public async global::System.Threading.Tasks.Task GetTextPrompts(string def, string response, string responseErrors, string startDate, string endDate, string exist, string textPromptsResultExists, string @__pickleIndex, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("def", def); + argumentsOfScenario.Add("response", response); + argumentsOfScenario.Add("responseErrors", responseErrors); + argumentsOfScenario.Add("startDate", startDate); + argumentsOfScenario.Add("endDate", endDate); + argumentsOfScenario.Add("exist", exist); + argumentsOfScenario.Add("textPromptsResultExists", textPromptsResultExists); + string pickleIndex = @__pickleIndex; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Get text prompts", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 7 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 8 + await testRunner.GivenAsync(string.Format("I have a definition \"{0}\"", def), ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 9 + await testRunner.AndAsync(string.Format("Text Prompt exist \"{0}\"", exist), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 10 + await testRunner.AndAsync(string.Format("text prompt within the date range exists \"{0}\"", textPromptsResultExists), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 11 + await testRunner.AndAsync(string.Format("I have a start date \"{0}\"", startDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 12 + await testRunner.AndAsync(string.Format("I have a end date \"{0}\"", endDate), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 13 + await testRunner.WhenAsync("I get the text prompt", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 14 + await testRunner.ThenAsync(string.Format("The response is \"{0}\"", response), ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 15 + await testRunner.AndAsync(string.Format("If the response has validation issues I see the \"{0}\" in the response", responseErrors), ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 16 + await testRunner.AndAsync("The response has a collection of text prompt", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 17 + await testRunner.AndAsync("Each text prompt has a Key", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 18 + await testRunner.AndAsync("Each text prompt has a Date greater than start date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 19 + await testRunner.AndAsync("Each text prompt has a Date less than end date", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsQueryStepDefinitions.cs b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsQueryStepDefinitions.cs new file mode 100644 index 0000000..ce162e1 --- /dev/null +++ b/src/Tests.Specs.Integration/TextGeneration/GetTextPromptsQueryStepDefinitions.cs @@ -0,0 +1,124 @@ +using Goodtocode.AgentFramework.Core.Application.TextGeneration; +using Goodtocode.AgentFramework.Core.Domain.TextGeneration; + +namespace Goodtocode.AgentFramework.Specs.Integration.TextGeneration; + +[Binding] +[Scope(Tag = "getTextPromptsQuery")] +public class GetTextPromptsQueryStepDefinitions : TestBase +{ + private bool _exists; + private bool _withinDateRangeExists; + private DateTime _endDate; + private DateTime _startDate; + private ICollection<TextPromptDto>? _response; + + [Given(@"I have a definition ""([^""]*)""")] + public void GivenIHaveADefinition(string def) + { + base.def = def; + } + + [Given(@"Text Prompt exist ""([^""]*)""")] + public void GivenTextPromptsExist(string exists) + { + bool.TryParse(exists, out _exists).ShouldBeTrue(); + } + + [Given(@"text prompt within the date range exists ""([^""]*)""")] + public void GivenTextPromptsWithinTheDateRangeExists(string withinDateRangeExists) + { + bool.TryParse(withinDateRangeExists, out _withinDateRangeExists).ShouldBeTrue(); + } + + [Given(@"I have a start date ""([^""]*)""")] + public void GivenIHaveAStartDate(string startDate) + { + if (string.IsNullOrWhiteSpace(startDate)) return; + DateTime.TryParse(startDate, out _startDate).ShouldBeTrue(); + _startDate = DateTime.UtcNow.AddMinutes(_withinDateRangeExists ? -1 : 1); //Handle for desired not-found scenarios + } + + [Given(@"I have a end date ""([^""]*)""")] + public void GivenIHaveAEndDate(string endDate) + { + if (string.IsNullOrWhiteSpace(endDate)) return; + DateTime.TryParse(endDate, out _endDate).ShouldBeTrue(); + } + + [When(@"I get the text prompt")] + public async Task WhenIGetTheTextPrompts() + { + if (_exists) + { + for (int i = 0; i < 2; i++) + { + var textPrompt = TextPromptEntity.Create(Guid.NewGuid(), Guid.Empty, "Tell me a bedtime story"); + context.TextResponses.Add(TextResponseEntity.Create(Guid.Empty, textPrompt.Id, "Once upon a time...")); + context.TextPrompts.Add(textPrompt); + } + ; + await context.SaveChangesAsync(CancellationToken.None); + } + + var request = new GetTextPromptsQuery() + { + StartDate = _startDate == default ? null : _startDate, + EndDate = _endDate == default ? null : _endDate + }; + + var validator = new GetTextPromptsQueryValidator(); + validationResponse = validator.Validate(request); + if (validationResponse.IsValid) + try + { + var handler = new GetTextPromptsQueryHandler(context); + _response = await handler.Handle(request, CancellationToken.None); + responseType = CommandResponseType.Successful; + } + catch (Exception e) + { + responseType = HandleAssignResponseType(e); + } + else + responseType = CommandResponseType.BadRequest; + } + + [Then(@"The response is ""([^""]*)""")] + public void ThenTheResponseIs(string response) + { + HandleHasResponseType(response); + } + + [Then(@"If the response has validation issues I see the ""([^""]*)"" in the response")] + public void ThenIfTheResponseHasValidationIssuesISeeTheInTheResponse(string expectedErrors) + { + HandleExpectedValidationErrorsAssertions(expectedErrors); + } + + [Then(@"The response has a collection of text prompt")] + public void ThenTheResponseHasACollectionOfTextPrompts() + { + _response?.Count.ShouldBe(_withinDateRangeExists == false ? 0 : _response.Count); + } + + [Then(@"Each text prompt has a Key")] + public void ThenEachTextPromptHasAKey() + { + _response?.FirstOrDefault(x => x.Id == default).ShouldBeNull(); + } + + [Then(@"Each text prompt has a Date greater than start date")] + public void ThenEachTextPromptHasADateGreaterThanStartDate() + { + if (_withinDateRangeExists) + _response?.FirstOrDefault(x => _startDate == default || x.Timestamp > _startDate).ShouldNotBeNull(); + } + + [Then(@"Each text prompt has a Date less than end date")] + public void ThenEachTextPromptHasADateLessThanEndDate() + { + if (_withinDateRangeExists) + _response?.FirstOrDefault(x => _endDate == default || x.Timestamp < _endDate).ShouldNotBeNull(); + } +} diff --git a/src/Tests.Specs.Integration/appsettings.test.json b/src/Tests.Specs.Integration/appsettings.test.json new file mode 100644 index 0000000..8fee16e --- /dev/null +++ b/src/Tests.Specs.Integration/appsettings.test.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "OpenAI": { + "ChatCompletionModelId": "gpt-4.1-nano", + "TextGenerationModelId": "gpt-3.5-turbo-instruct", + "TextEmbeddingModelId": "text-embedding-3-small", + "TextModerationModelId": "text-moderation-latest", + "ImageModelId": "dall-e-3", + "AudioModelId": "tts-1", + "ApiKey": "" + } +} \ No newline at end of file diff --git a/src/build.cmd b/src/build.cmd new file mode 100644 index 0000000..ae89052 --- /dev/null +++ b/src/build.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +cd "%~dp0" +dotnet build --configuration Release --interactive ^ + && dotnet test --configuration Release --no-build --no-restore --interactive \ No newline at end of file diff --git a/src/build.sh b/src/build.sh new file mode 100644 index 0000000..fa86913 --- /dev/null +++ b/src/build.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -e + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +pushd "$SCRIPT_DIR" > /dev/null + +# Release config triggers also "dotnet format" +dotnet build --configuration Release --interactive +dotnet test --configuration Release --no-build --no-restore --interactive + +popd > /dev/null \ No newline at end of file diff --git a/src/nuget.config b/src/nuget.config new file mode 100644 index 0000000..24d22ee --- /dev/null +++ b/src/nuget.config @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<configuration> + + <packageSources> + <clear /> + <add key="nuget.org" value="https://api.nuget.org/v3/index.json" /> + </packageSources> + + <packageSourceMapping> + <packageSource key="nuget.org"> + <package pattern="*" /> + </packageSource> + </packageSourceMapping> + +</configuration> \ No newline at end of file From 55d15bb70310023ea30be7e2c9d189da130a9eb0 Mon Sep 17 00:00:00 2001 From: Robert Good <robert.good@goodtocode.com> Date: Sat, 31 Jan 2026 23:21:46 -0800 Subject: [PATCH 2/8] iac and ci/cd --- ...ndingzone-standalone-web-api-sql-dev.bicepparam | 2 +- ...dalone-iac.yml => gtc-agent-standalone-iac.yml} | 0 ...ql.yml => gtc-agent-standalone-web-api-sql.yml} | 14 +++++++------- 3 files changed, 8 insertions(+), 8 deletions(-) rename .github/workflows/{gtc-semker-standalone-iac.yml => gtc-agent-standalone-iac.yml} (100%) rename .github/workflows/{gtc-semker-standalone-web-api-sql.yml => gtc-agent-standalone-web-api-sql.yml} (97%) diff --git a/.azure/variables/landingzone-standalone-web-api-sql-dev.bicepparam b/.azure/variables/landingzone-standalone-web-api-sql-dev.bicepparam index 1135844..5c41099 100644 --- a/.azure/variables/landingzone-standalone-web-api-sql-dev.bicepparam +++ b/.azure/variables/landingzone-standalone-web-api-sql-dev.bicepparam @@ -1,7 +1,7 @@ using '../templates/landingzone-standalone-web-api-sql.bicep' // Common -var productIac = 'semker' +var productIac = 'agent' var environmentIac = 'dev' var regionIac = 'wus2' var instanceIac = '001' diff --git a/.github/workflows/gtc-semker-standalone-iac.yml b/.github/workflows/gtc-agent-standalone-iac.yml similarity index 100% rename from .github/workflows/gtc-semker-standalone-iac.yml rename to .github/workflows/gtc-agent-standalone-iac.yml diff --git a/.github/workflows/gtc-semker-standalone-web-api-sql.yml b/.github/workflows/gtc-agent-standalone-web-api-sql.yml similarity index 97% rename from .github/workflows/gtc-semker-standalone-web-api-sql.yml rename to .github/workflows/gtc-agent-standalone-web-api-sql.yml index 7e3518a..4c671ba 100644 --- a/.github/workflows/gtc-semker-standalone-web-api-sql.yml +++ b/.github/workflows/gtc-agent-standalone-web-api-sql.yml @@ -49,7 +49,7 @@ jobs: env: SCRIPTS_PATH: "./.github/scripts" SRC_PATH: "./src" - SRC_SLN: "SemanticKernelBlazor.sln" + SRC_SLN: "AgentFrameworkBlazor.sln" API_PATH: "Presentation.WebApi" API_PROJECT: "Presentation.WebApi.csproj" TEST_PATH: "Tests.Specs.Integration" @@ -58,7 +58,7 @@ jobs: WEB_PROJECT: "Presentation.Blazor.csproj" INFRA_PATH: 'Infrastructure.SqlServer' INFRA_PROJECT: 'Infrastructure.SqlServer.csproj' - INFRA_DBCONTEXT: 'SemanticKernelContext' + INFRA_DBCONTEXT: 'AgentFrameworkContext' RUNTIME_ENV: "Development" CONFIGURATION: "Release" VERSION_MAJOR: '2' @@ -220,13 +220,13 @@ jobs: matrix: DOTNET_VERSION: ["10.x"] env: - APPI_NAME: 'semker-dev-wus2-001-appi' - AZURE_APIAPP_NAME: semker-dev-wus2-001-api + APPI_NAME: 'agent-dev-wus2-001-appi' + AZURE_APIAPP_NAME: agent-dev-wus2-001-api AZURE_WEBAPP_PACKAGE_PATH: '.' - AZURE_WEBAPP_NAME: semker-dev-wus2-001-web + AZURE_WEBAPP_NAME: agent-dev-wus2-001-web AZURE_RG_NAME: 'gtc-agent-dev-wus2-001-rg' - SQL_NAME: 'semker-dev-wus2-001-sql' - SQLDB_NAME: 'semker-dev-wus2-001-sqldb' + SQL_NAME: 'agent-dev-wus2-001-sql' + SQLDB_NAME: 'agent-dev-wus2-001-sqldb' RUNTIME_ENV: "Development" steps: From 4168180711bf2803afcc9628911900942983346e Mon Sep 17 00:00:00 2001 From: Robert Good <robert.good@goodtocode.com> Date: Sat, 31 Jan 2026 23:42:37 -0800 Subject: [PATCH 3/8] mask mellow --- .azure/modules/bot-botservice.bicep | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.azure/modules/bot-botservice.bicep b/.azure/modules/bot-botservice.bicep index 907d131..dec890e 100644 --- a/.azure/modules/bot-botservice.bicep +++ b/.azure/modules/bot-botservice.bicep @@ -5,7 +5,7 @@ param name string -@description('The SKU (pricing tier) for the Bot Service. Allowed values: F0 (Free), S1 (Standard). Default is S1.') +@description('The SKU for the Bot Service. Allowed values: F0 Free, S1 Standard. Default is S1.') @allowed([ 'F0' 'S1' @@ -24,6 +24,11 @@ param msAppValue string @maxLength(64) param displayName string = '' + +@description('The endpoint URL for the Bot Service. Example: https://your-bot-endpoint/api/messages') +@minLength(1) +param endpoint string + @description('Tags to apply to the Bot Service resource.') param tags object = {} @@ -31,8 +36,8 @@ var location = resourceGroup().location var uniqueSuffix = toLower(substring(uniqueString(resourceGroup().id, 'Microsoft.BotService/bots', name), 0, 6)) var botDisplayName = empty(displayName) ? name : displayName var kvName = 'kv-${name}' -var appPasswordSecret = 'bot-${replace(name, '_', '-')}-pwd-${uniqueSuffix}' -var appPasswordSecretId = empty(msAppValue) ? '' : keyVaultName_appPasswordSecret.id +var appCredentialName = 'bot-${replace(name, '_', '-')}-cred-${uniqueSuffix}' +var appCredentialId = empty(msAppValue) ? '' : keyVaultName_appCredential.id resource keyVaultName 'Microsoft.KeyVault/vaults@2023-07-01' = { name: kvName @@ -48,10 +53,9 @@ resource keyVaultName 'Microsoft.KeyVault/vaults@2023-07-01' = { } } - -resource keyVaultName_appPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(msAppValue)) { +resource keyVaultName_appCredential 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(msAppValue)) { parent: keyVaultName - name: appPasswordSecret + name: appCredentialName properties: { value: msAppValue } @@ -70,7 +74,7 @@ resource name_resource 'Microsoft.BotService/botServices@2022-09-15' = { displayName: botDisplayName msaAppId: msAppId openWithHint: 'bfcomposer://' - appPasswordHint: appPasswordSecretId - endpoint: 'https://REPLACE-WITH-YOUR-BOT-ENDPOINT/api/messages' + appPasswordHint: appCredentialId + endpoint: endpoint } } From a03c882cf32fd854b29021efcc4ff9440ce19bd6 Mon Sep 17 00:00:00 2001 From: Robert Good <robert.good@goodtocode.com> Date: Sat, 31 Jan 2026 23:52:10 -0800 Subject: [PATCH 4/8] removed bot --- .azure/modules/bot-botservice.bicep | 80 ----------------------------- 1 file changed, 80 deletions(-) delete mode 100644 .azure/modules/bot-botservice.bicep diff --git a/.azure/modules/bot-botservice.bicep b/.azure/modules/bot-botservice.bicep deleted file mode 100644 index dec890e..0000000 --- a/.azure/modules/bot-botservice.bicep +++ /dev/null @@ -1,80 +0,0 @@ - -@description('The name of the Bot Service resource. Must be 2-64 characters, using only letters, numbers, and hyphens, starting and ending with a letter or number.') -@minLength(2) -@maxLength(64) -param name string - - -@description('The SKU for the Bot Service. Allowed values: F0 Free, S1 Standard. Default is S1.') -@allowed([ - 'F0' - 'S1' -]) -param sku string = 'S1' - -@description('The Microsoft App ID for the Bot Service. Must be a valid GUID.') -@minLength(1) -param msAppId string - -@description('The Microsoft App password/secret value for the Bot Service. Required for authentication.') -@minLength(1) -param msAppValue string - -@description('The display name for the Bot Service. Optional, defaults to the resource name if not provided.') -@maxLength(64) -param displayName string = '' - - -@description('The endpoint URL for the Bot Service. Example: https://your-bot-endpoint/api/messages') -@minLength(1) -param endpoint string - -@description('Tags to apply to the Bot Service resource.') -param tags object = {} - -var location = resourceGroup().location -var uniqueSuffix = toLower(substring(uniqueString(resourceGroup().id, 'Microsoft.BotService/bots', name), 0, 6)) -var botDisplayName = empty(displayName) ? name : displayName -var kvName = 'kv-${name}' -var appCredentialName = 'bot-${replace(name, '_', '-')}-cred-${uniqueSuffix}' -var appCredentialId = empty(msAppValue) ? '' : keyVaultName_appCredential.id - -resource keyVaultName 'Microsoft.KeyVault/vaults@2023-07-01' = { - name: kvName - location: location - properties: { - tenantId: subscription().tenantId - sku: { - family: 'A' - name: 'standard' - } - accessPolicies: [] - enabledForTemplateDeployment: true - } -} - -resource keyVaultName_appCredential 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(msAppValue)) { - parent: keyVaultName - name: appCredentialName - properties: { - value: msAppValue - } -} - - -resource name_resource 'Microsoft.BotService/botServices@2022-09-15' = { - name: name - kind: 'azurebot' - location: 'global' - sku: { - name: sku - } - tags: tags - properties: { - displayName: botDisplayName - msaAppId: msAppId - openWithHint: 'bfcomposer://' - appPasswordHint: appCredentialId - endpoint: endpoint - } -} From d251e389abae2ffa603630eacf3b9012092bf493 Mon Sep 17 00:00:00 2001 From: Robert Good <robert.good@goodtocode.com> Date: Sun, 1 Feb 2026 01:04:00 -0800 Subject: [PATCH 5/8] ready to test --- .../cd/New-Github-Azure-Federation.ps1 | 90 ++++++++++++++----- .github/scripts/repo/New-GithubSecret.ps1 | 2 + README.md | 6 ++ 3 files changed, 76 insertions(+), 22 deletions(-) diff --git a/.github/scripts/cd/New-Github-Azure-Federation.ps1 b/.github/scripts/cd/New-Github-Azure-Federation.ps1 index 9965ce9..3b70ebd 100644 --- a/.github/scripts/cd/New-Github-Azure-Federation.ps1 +++ b/.github/scripts/cd/New-Github-Azure-Federation.ps1 @@ -6,21 +6,23 @@ # 3. Change directory to the script folder: # CD C:\Scripts (wherever your script is) # 4. In powershell, run script: -# .\New-AzureGitHubFederation.ps1 -SubscriptionId 12343dac-0e69-436a-866b-456727dd3579 -PrincipalName myco-github-devtest-001 -Organization mygithuborg -Repository mygithubrepo -Environment development +# .\New-Github-Azure-Federation.ps1 -TenantId 12343dac-0e69-436a-866b-456727dd3579 -SubscriptionId 12343dac-0e69-436a-866b-456727dd3579 -PrincipalName myco-github-devtest-001 -Organization mygithuborg -Repository mygithubrepo -Environment development #################################################################################### param ( - [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] - [guid]$SubscriptionId = $(throw '-SubscriptionId is a required parameter.'), #12343dac-0e69-436a-866b-456727dd3579 - [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] - [string]$PrincipalName = $(throw '-PrincipalName is a required parameter.'), #Example: COMPANY-SUB_OR_PRODUCTLINE-github-001 - [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] - [string]$Organization = $(throw '-Organization is a required parameter.'), #GitHub Organization Name - [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] - [string]$Repository = $(throw '-Repository is a required parameter.'), #GitHub Repository Name - [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] - [string]$Environment = $(throw '-Environment is a required parameter.') #GitHub Repository Environment: development, production - ) + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [guid]$TenantId = $(throw '-TenantId is a required parameter.'), #12343dac-0e69-436a-866b-456727dd3579 + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [guid]$SubscriptionId = $(throw '-SubscriptionId is a required parameter.'), #12343dac-0e69-436a-866b-456727dd3579 + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [string]$PrincipalName = $(throw '-PrincipalName is a required parameter.'), #Example: COMPANY-SUB_OR_PRODUCTLINE-github-001 + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [string]$Organization = $(throw '-Organization is a required parameter.'), #GitHub Organization Name + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [string]$Repository = $(throw '-Repository is a required parameter.'), #GitHub Repository Name + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [string]$Environment = $(throw '-Environment is a required parameter.') #GitHub Repository Environment: development, production +) #################################################################################### Set-ExecutionPolicy Unrestricted -Scope Process -Force $VerbosePreference = 'SilentlyContinue' # 'SilentlyContinue' # 'Continue' @@ -31,16 +33,34 @@ Write-Host "*****************************" Write-Host "*** Starting: $ThisScript On: $(Get-Date)" Write-Host "*****************************" #################################################################################### -# Install required modules -Install-Module Az.Accounts,Az.Resources -Scope CurrentUser -Force +# Install required modules idempotently +$modules = @('Az.Accounts', 'Az.Resources') +foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "Installing module: $module" + Install-Module $module -Scope CurrentUser -Force + } else { + Write-Host "Module $module already installed." + } +} + +# Login to Azure idempotently +$currentContext = $null +try { + $currentContext = Get-AzContext -ErrorAction Stop +} catch {} -# Login to Azure -Connect-AzAccount -SubscriptionId $SubscriptionId -UseDeviceAuthentication +if ($null -eq $currentContext -or $currentContext.Subscription.Id -ne $SubscriptionId) { + Write-Host "Logging in to Azure with SubscriptionId: $SubscriptionId" + Connect-AzAccount -TenantId $TenantId -SubscriptionId $SubscriptionId +} else { + Write-Host "Already logged in to the correct Azure subscription." +} # Get App Registration object (Application object) $app = Get-AzADApplication -DisplayName $PrincipalName if (-not $app) { - $app = New-AzADApplication -DisplayName $PrincipalName + $app = New-AzADApplication -DisplayName $PrincipalName } Write-Host "App Registration (Client) Id: $($app.AppId)" $clientId = $app.AppId @@ -49,21 +69,47 @@ $appObjectId = $app.Id # Create Service Principal and assign role $sp = Get-AzADServicePrincipal -DisplayName $PrincipalName if (-not $sp) { - $sp = New-AzADServicePrincipal -ApplicationId $clientId + $sp = New-AzADServicePrincipal -ApplicationId $clientId } Write-Host "Service Principal Id: $($sp.Id)" $spObjectId = $sp.Id -New-AzRoleAssignment -ObjectId $spObjectId -RoleDefinitionName Contributor -Scope "/subscriptions/$SubscriptionId" + +# Idempotent Role Assignment +$roleAssignment = Get-AzRoleAssignment -ObjectId $spObjectId -RoleDefinitionName Contributor -Scope "/subscriptions/$SubscriptionId" -ErrorAction SilentlyContinue +if (-not $roleAssignment) { + New-AzRoleAssignment -ObjectId $spObjectId -RoleDefinitionName Contributor -Scope "/subscriptions/$SubscriptionId" +} +else { + Write-Host "Role assignment already exists." +} $tenantId = (Get-AzContext).Subscription.TenantId # Create new App Registration Federated Credentials for the GitHub operations $subjectRepo = "repo:" + $Organization + "/" + $Repository + ":environment:" + $Environment -New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-repo" -Subject "$subjectRepo" +$existingCredRepo = Get-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq "$PrincipalName-repo" } +if (-not $existingCredRepo) { + New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-repo" -Subject "$subjectRepo" +} +else { + Write-Host "Federated credential $PrincipalName-repo already exists." +} $subjectRepoMain = "repo:" + $Organization + "/" + $Repository + ":ref:refs/heads/main" -New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-main" -Subject "$subjectRepoMain" +$existingCredMain = Get-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq "$PrincipalName-main" } +if (-not $existingCredMain) { + New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-main" -Subject "$subjectRepoMain" +} +else { + Write-Host "Federated credential $PrincipalName-main already exists." +} $subjectRepoPR = "repo:" + $Organization + "/" + $Repository + ":pull_request" -New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-PR" -Subject "$subjectRepoPR" +$existingCredPR = Get-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq "$PrincipalName-PR" } +if (-not $existingCredPR) { + New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-PR" -Subject "$subjectRepoPR" +} +else { + Write-Host "Federated credential $PrincipalName-PR already exists." +} Write-Host "AZURE_TENANT_ID: $tenantId" Write-Host "AZURE_SUBSCRIPTION_ID: $SubscriptionId" diff --git a/.github/scripts/repo/New-GithubSecret.ps1 b/.github/scripts/repo/New-GithubSecret.ps1 index fad416a..b507277 100644 --- a/.github/scripts/repo/New-GithubSecret.ps1 +++ b/.github/scripts/repo/New-GithubSecret.ps1 @@ -12,6 +12,8 @@ # # .\New-GithubSecret.ps1 -Owner goodtocode -Repo my-repo -Environment development -SecretName MY_SECRET -SecretValue "secret-value" # +# @{API_CLIENT_ID="your-api-client-id";AZURE_CLIENT_ID="your-azure-client-id";AZURE_SUBSCRIPTION_ID="your-azure-subscription-id";AZURE_TENANT_ID="your-azure-tenant-id";EEID_TENANT_ID="your-eeid-tenant-id";OPENAI_APIKEY="your-openai-apikey";SQL_ADMIN_PASSWORD="your-sql-admin-password";SQL_ADMIN_USER="your-sql-admin-user";WEB_CLIENT_ID="your-web-client-id";WEB_CLIENT_SECRET="your-web-client-secret"} | ForEach-Object {./github/scripts/repo/New-GithubSecret.ps1 -Owner your-github-handle -Repo your-repo -Environment development -SecretName $_.Key -SecretValue $_.Value} +# param( [Parameter(Mandatory=$true)][string]$Owner, [Parameter(Mandatory=$true)][string]$Repo, diff --git a/README.md b/README.md index 8f14d5f..40e23b2 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,12 @@ Navigate to: http://localhost:7777/swagger/index.html in your browser to the Swa # DevOps Configuration for Azure IaC and CI/CD +## Github Environment Secrets (development) +The typical set of GitHub environment secrets, i.e. development, can be inserted below with this powershell script. +``` +@{API_CLIENT_ID="your-api-client-id";AZURE_CLIENT_ID="your-azure-client-id";AZURE_SUBSCRIPTION_ID="your-azure-subscription-id";AZURE_TENANT_ID="your-azure-tenant-id";EEID_TENANT_ID="your-eeid-tenant-id";OPENAI_APIKEY="your-openai-apikey";SQL_ADMIN_PASSWORD="your-sql-admin-password";SQL_ADMIN_USER="your-sql-admin-user";WEB_CLIENT_ID="your-web-client-id";WEB_CLIENT_SECRET="your-web-client-secret"} | ForEach-Object {./github/scripts/repo/New-GithubSecret.ps1 -Owner your-github-handle -Repo your-repo -Environment development -SecretName $_.Key -SecretValue $_.Value} +``` + ## GitHub Actions (.github folder) The GitHub action will automatically run upon commit to a repo. The triggers are set based on changes (PRs/Merges) to the main branch. From 04916bded19d4f3e0838f926edaae297ac3644b7 Mon Sep 17 00:00:00 2001 From: Robert Good <robert.good@goodtocode.com> Date: Sun, 1 Feb 2026 01:32:54 -0800 Subject: [PATCH 6/8] updated docs --- README.md | 155 +++++++++++++++++++++++++----------------------------- 1 file changed, 73 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 40e23b2..de53c3a 100644 --- a/README.md +++ b/README.md @@ -303,94 +303,97 @@ dotnet run --project src/Presentation.WebApi/Presentation.WebApi.csproj Open Microsoft Edge or modern browser Navigate to: http://localhost:7777/swagger/index.html in your browser to the Swagger API Interface +# Github Actions for Azure IaC and CI/CD +## GitHub Actions (.github folder) -# DevOps Configuration for Azure IaC and CI/CD -## Github Environment Secrets (development) -The typical set of GitHub environment secrets, i.e. development, can be inserted below with this powershell script. -``` -@{API_CLIENT_ID="your-api-client-id";AZURE_CLIENT_ID="your-azure-client-id";AZURE_SUBSCRIPTION_ID="your-azure-subscription-id";AZURE_TENANT_ID="your-azure-tenant-id";EEID_TENANT_ID="your-eeid-tenant-id";OPENAI_APIKEY="your-openai-apikey";SQL_ADMIN_PASSWORD="your-sql-admin-password";SQL_ADMIN_USER="your-sql-admin-user";WEB_CLIENT_ID="your-web-client-id";WEB_CLIENT_SECRET="your-web-client-secret"} | ForEach-Object {./github/scripts/repo/New-GithubSecret.ps1 -Owner your-github-handle -Repo your-repo -Environment development -SecretName $_.Key -SecretValue $_.Value} -``` +The `.github/workflows` folder contains the GitHub Actions pipelines for CI/CD. Below is a summary of the two main workflow files, their purposes, and triggers: -## GitHub Actions (.github folder) -The GitHub action will automatically run upon commit to a repo. The triggers are set based on changes (PRs/Merges) to the main branch. +### Triggers +All workflow YAML files in this repo are designed to: +- **Trigger CI**: On any Pull Request (PR) to any branch (runs build/test/validate only) +- **Trigger CD**: On push to the `main` branch (runs full deployment) + +| Workflow File | Purpose | CI Trigger (PR) | CD Trigger (Push to main) | +|--------------------------------------|-----------------------------------------------------------------------------------------|-------------------------|---------------------------| +| `COMPANY-PRODUCT-api.yml` | CI/CD for .NET Web API (build, test, deploy to Azure App Service) | Yes | Yes | +| `COMPANY-PRODUCT-api-sql.yml` | CI/CD for .NET Web API with Azure SQL (includes DB migration) | Yes | Yes | +| `COMPANY-PRODUCT-iac.yml` | Deploy Azure infrastructure using Bicep templates | Yes | Yes | +| `COMPANY-PRODUCT-stapp-ci-cd.yml` | CI/CD for Azure Static Web Apps (Blazor, SPA, etc.) | Yes | Yes | +| `COMPANY-PRODUCT-nuget.yml` | Build, test, and publish NuGet packages | Yes | Yes | +--- + +### Setting up GitHub Actions to Deploy to Azure + +Follow these steps to configure your environment for GitHub Actions CI/CD and Azure deployment: + +**Step 1: Create EEID Web and API App Registrations** -### Azure Federation to GitHub Actions -gtc-rg-AgentFramework-infrastructure.yml will deploy all necessary resources to Azure. To enable this functionality, two service principles are required: App Registration service principle (used for az login command) and a Enterprise Application service principle (allows GitHub to authenticate to Azure). -#### Git Hub Environment Secret setup and Azure IAM privileges: -Note: The AZURE_SECRETS method uses: az ad sp create-for-rbac --name "COMPANY-SUB_OR_PRODUCTLINE-github-001" --role contributor --scopes /subscriptions/SUBSCRIPTION_ID --json-auth +Use the provided PowerShell script to create both the Web and API app registrations in your Entra External ID (EEID) tenant. Replace the placeholders with your actual values: -[New-AzureGitHubFederation.ps1](https://github.com/goodtocode/cloud-admin/blob/main/scripts/cybersecurity/Azure-GitHub-Federation/New-AzureGitHubFederation.ps1) +```powershell +pwsh -File ./.azure/scripts/entra/New-EntraAppRegistrations.ps1 \ + -EntraInstanceUrl "https://<your-tenant-name>.ciamlogin.com" \ + -TenantId "<your-tenant-id>" \ + -WebAppRegistrationName "<web-app-registration-name>" \ + -ApiAppRegistrationName "<api-app-registration-name>" \ + -WebProjectPath "./src/Presentation.Blazor" \ + -ApiProjectPath "./src/Presentation.WebApi" ``` -# Install required modules -Install-Module Az.Accounts,Az.Resources -Scope CurrentUser -Force -# Login to Azure -Connect-AzAccount -SubscriptionId $SubscriptionId -UseDeviceAuthentication +This script will output the required IDs and URIs for your environment. -# Get App Registration object (Application object) -$app = Get-AzADApplication -DisplayName $PrincipalName -if (-not $app) { - $app = New-AzADApplication -DisplayName $PrincipalName -} -Write-Host "App Registration (Client) Id: $($app.AppId)" -$clientId = $app.AppId -$appObjectId = $app.Id - -# Create Service Principal and assign role -$sp = Get-AzADServicePrincipal -DisplayName $PrincipalName -if (-not $sp) { - $sp = New-AzADServicePrincipal -ApplicationId $clientId -} -Write-Host "Service Principal Id: $($sp.Id)" -$spObjectId = $sp.Id -New-AzRoleAssignment -ObjectId $spObjectId -RoleDefinitionName Contributor -Scope "/subscriptions/$SubscriptionId" +**Step 2: Set GitHub Environment Secrets** -$tenantId = (Get-AzContext).Subscription.TenantId +Set the required secrets in your GitHub repository for the deployment workflows. You can use the provided script, replacing the placeholders with your actual values: -# Create new App Registration Federated Credentials for the GitHub operations -$subjectRepo = "repo:" + $Organization + "/" + $Repository + ":environment:" + $Environment -New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-repo" -Subject "$subjectRepo" -$subjectRepoMain = "repo:" + $Organization + "/" + $Repository + ":ref:refs/heads/main" -New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-main" -Subject "$subjectRepoMain" -$subjectRepoPR = "repo:" + $Organization + "/" + $Repository + ":pull_request" -New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-PR" -Subject "$subjectRepoPR" +```powershell +$secrets = @{ + API_CLIENT_ID = "<api-app-client-id>" + AZURE_CLIENT_ID = "<azure-client-id>" + AZURE_SUBSCRIPTION_ID= "<azure-subscription-id>" + AZURE_TENANT_ID = "<azure-tenant-id>" + EEID_TENANT_ID = "<eeid-tenant-id>" + OPENAI_APIKEY = "<openai-api-key>" + SQL_ADMIN_PASSWORD = "<sql-admin-password>" + SQL_ADMIN_USER = "<sql-admin-user>" + WEB_CLIENT_ID = "<web-app-client-id>" + WEB_CLIENT_SECRET = "<web-app-client-secret>" +} -Write-Host "AZURE_TENANT_ID: $tenantId" -Write-Host "AZURE_SUBSCRIPTION_ID: $SubscriptionId" -Write-Host "AZURE_CLIENT_ID: $clientId" +$secrets.GetEnumerator() | ForEach-Object { + ./.github/scripts/repo/New-GithubSecret.ps1 \ + -Owner <github-org-or-user> \ + -Repo <repo-name> \ + -Environment <environment-name> \ + -SecretName $_.Key \ + -SecretValue $_.Value +} ``` -## Azure DevOps Pipelines (.azure-devops folder) -Azure DevOps pipelines require an Azure Service Connection to authenticate and deploy resources to Azure. +If you are using a hub-and-spoke topology, also set: -# Entity Framework vs. Semantic Kernel Memory -This example uses Entity Framework (EF) to store messages and responses for Semantic Kernel, and does not rely on SK Memory (SM). EF and SM serve different purposes. If you need natural language querying and efficient indexing, Semantic Kernel Memory is a great fit. If you’re building a standard application with a relational database, Entity Framework is more appropriate. - -The key differences between Entity Framework (EF) and Semantic Kernel memory: +```powershell +PLATFORM_SUBSCRIPTION_ID="<platform-subscription-id>" +``` -## Purpose and Functionality -- Entity Framework (EF): EF is an Object-Relational Mapping (ORM) framework for .NET applications. It allows you to map database tables to C# classes and provides an abstraction layer for database operations. EF focuses on CRUD (Create, Read, Update, Delete) operations and data modeling. -- Semantic Kernel Memory: Semantic Kernel Memory (SM) is part of the Semantic Kernel project. It’s a library for C#, Python, and Java that wraps direct calls to databases and supports vector search. SM is designed for long-term memory and efficient indexing of datasets. It’s particularly useful for natural language querying and retrieval augmented generation (RAG). +**Step 3: Federate Azure Subscription and GitHub Repo** -## Data Storage and Retrieval -- EF: EF stores data in relational databases (e.g., SQL Server, MySQL, PostgreSQL). It uses SQL queries to retrieve data. -- SM: SM can use various storage mechanisms, including vector databases. It supports vector search, which allows efficient similarity-based retrieval. SM is well-suited for handling large volumes of data and complex queries. +Run the following script to federate your Azure subscription with your GitHub repository. Replace the placeholders with your actual values: -## Querying -- EF: EF queries are typically written in LINQ (Language Integrated Query) or SQL. You express queries in terms of C# objects and properties. -- SM: SM supports natural language querying. You can search for information using text-based queries, making it more user-friendly for applications like chatbots. +```powershell +pwsh -File ./.github/scripts/repo/New-Github-Azure-Federation.ps1 \ + -TenantId "<azure-tenant-id>" \ + -SubscriptionId "<azure-subscription-id>" \ + -PrincipalName "<federated-identity-name>" \ + -Organization "<github-org-or-user>" \ + -Repository "<repo-name>" \ + -Environment "<environment-name>" +``` -## Integration with Chat Systems -- EF: EF doesn’t directly integrate with chat systems. It’s primarily used for data persistence. -- SM: SM integrates seamlessly with chat systems like ChatGPT, Copilot, and Semantic Kernel. It enhances data-driven features in AI applications. +--- -## Scalability and Performance -- EF: EF is suitable for small to medium-sized applications. It may not perform optimally with very large datasets. -- SM: SM is designed for scalability. It can handle large volumes of data efficiently, making it suitable for memory-intensive applications. +This setup ensures your GitHub Actions workflows can securely deploy to Azure using federated credentials and the required secrets. -## Use Cases -- EF: Use EF for traditional CRUD operations, business logic, and data modeling. -- SM: Use SM for long-term memory, chatbots, question-answering systems, and information retrieval. # Contact * [GitHub Repo](https://www.github.com/goodtocode/agent-framework-quick-start) @@ -421,18 +424,6 @@ The key differences between Entity Framework (EF) and Semantic Kernel memory: | Version | Date | Release Notes | |---------|-------------|------------------------------------------------------------------| -| 1.0.0 | 2024-Aug-05 | Initial WebAPI Release | -| 1.0.1 | 2024-Oct-27 | Updated Azure IaC ESA/CAF Standards | -| 1.0.2 | 2025-Jan-19 | Updated to .NET 9 and SK 1.33 | -| 1.0.3 | 2025-Feb-09 | Remove projects from File-New Project | -| 1.1.0 | 2025-Jun-04 | Blazor copilot-ish UX, AuthorSession | -| 1.1.1 | 2025-Jun-07 | Authors, Sessions & Messages Plugins | -| 1.1.2 | 2025-Aug-16 | Deprecated Specflow, Automapper (removed from solution) | -| 1.1.5 | 2025-Aug-18 | Deprecated FluentValidation/Assertions (removed from solution) | -| 1.1.6 | 2025-Aug-19 | Fixed blazor copilot chat runtime error | -| 1.1.7 | 2025-Aug-22 | Deprecated MediatR (removed from solution) | -| 1.1.8 | 2025-Aug-23 | Updated docs. Fixed runtime message post | -| 1.1.9 | 2025-Oct-31 | Added build/test precursor, plugin compatibility, improved code coverage | -| 2.0.0 | 2026-Feb-02 | Blazor Fluent UI (Microsoft.Aspnetcore.FluentUI) fluentui-blazor.net | +| 1.0.0 | 2026-Feb-02 | Initial Release | This project is licensed with the [MIT license](https://mit-license.org/). \ No newline at end of file From c0f649cf9580fe252950f6a65bcb5193ddf95c74 Mon Sep 17 00:00:00 2001 From: Robert Good <robert.good@goodtocode.com> Date: Sun, 1 Feb 2026 01:34:25 -0800 Subject: [PATCH 7/8] fixed solution --- .github/workflows/gtc-agent-standalone-web-api-sql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gtc-agent-standalone-web-api-sql.yml b/.github/workflows/gtc-agent-standalone-web-api-sql.yml index 4c671ba..8351061 100644 --- a/.github/workflows/gtc-agent-standalone-web-api-sql.yml +++ b/.github/workflows/gtc-agent-standalone-web-api-sql.yml @@ -49,7 +49,7 @@ jobs: env: SCRIPTS_PATH: "./.github/scripts" SRC_PATH: "./src" - SRC_SLN: "AgentFrameworkBlazor.sln" + SRC_SLN: "Goodtocode.AgentFramework.Blazor.sln" API_PATH: "Presentation.WebApi" API_PROJECT: "Presentation.WebApi.csproj" TEST_PATH: "Tests.Specs.Integration" From 7f1da7e806a6ef0cf92047f3927b94bb8f6a814b Mon Sep 17 00:00:00 2001 From: Robert Good <robert.good@goodtocode.com> Date: Sun, 1 Feb 2026 01:57:57 -0800 Subject: [PATCH 8/8] updated SK to AF --- .github/copilot-instructions.md | 8 ++++---- README.md | 10 +++++----- ...anticKernelContext.cs => IAgentFrameworkContext.cs} | 0 src/Goodtocode.AgentFramework.WebApi.sln | 2 +- ...apshot.cs => AgentFrameworkContextModelSnapshot.cs} | 0 ...manticKernelContext.cs => AgentFrameworkContext.cs} | 0 6 files changed, 10 insertions(+), 10 deletions(-) rename src/Core.Application/Abstractions/{ISemanticKernelContext.cs => IAgentFrameworkContext.cs} (100%) rename src/Infrastructure.SqlServer/Migrations/{SemanticKernelContextModelSnapshot.cs => AgentFrameworkContextModelSnapshot.cs} (100%) rename src/Infrastructure.SqlServer/Persistence/{SemanticKernelContext.cs => AgentFrameworkContext.cs} (100%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c49339d..68a94e6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,7 +9,7 @@ - **Frontend:** `src/Presentation.Blazor/` (Blazor WebAssembly) - **Backend:** `src/Presentation.WebApi/` (ASP.NET Core Web API) - **Core Logic:** `src/Core.Application/`, `src/Core.Domain/` -- **AI Integration:** `src/Infrastructure.SemanticKernel/` (Semantic Kernel plugins, prompt orchestration) +- **AI Integration:** `src/Infrastructure.AgentFramework/` (Semantic Kernel plugins, prompt orchestration) - **Persistence:** `src/Infrastructure.SqlServer/` (SQL Server, migrations) - **IaC:** `data/` (SQL), Azure Bicep in deployment scripts @@ -28,15 +28,15 @@ - See `docs/naming-conventions.md` for details. ## Integration & Extensibility -- **Semantic Kernel:** Add plugins in `src/Infrastructure.SemanticKernel/Plugins/`. -- **External Integrations:** Use `src/Core.Application/Common/` and `src/Infrastructure.SemanticKernel/` for connectors. +- **Semantic Kernel:** Add plugins in `src/Infrastructure.AgentFramework/Plugins/`. +- **External Integrations:** Use `src/Core.Application/Common/` and `src/Infrastructure.AgentFramework/` for connectors. - **RBAC & Security:** Enforced in API layer, see `ConfigureServicesAuth.cs`. ## References - [README.md](../README.md): Project overview and getting started ## Examples -- To add a new agent plugin: create in `src/Infrastructure.SemanticKernel/Plugins/`, register in `ConfigureServices.cs`. +- To add a new agent plugin: create in `src/Infrastructure.AgentFramework/Plugins/`, register in `ConfigureServices.cs`. - To add a new API endpoint: implement in `src/Presentation.WebApi/`, follow API naming conventions. --- diff --git a/README.md b/README.md index de53c3a..8a93aad 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ To get started, follow the steps below: 5. Create your SQL Server database & schema (via *dotnet ef* command) ``` cd ../../ - dotnet ef database update --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context SemanticKernelContext --connection "Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=SemanticKernel;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30" + dotnet ef database update --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context AgentFrameworkContext --connection "Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=AgentFramework;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30" ``` 6. Run Tests (Tests.Specs.Integration) ``` @@ -273,23 +273,23 @@ dotnet user-secrets set "ConnectionStrings:DefaultConnection" "YOUR_SQL_CONNECTI 3. (Optional) If you have an existing database, scaffold current entities into your project ``` - dotnet ef dbcontext scaffold "Data Source=localhost;Initial Catalog=SemanticKernel;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;" Microsoft.EntityFrameworkCore.SqlServer -t WeatherForecastView -c WeatherChannelContext -f -o WebApi + dotnet ef dbcontext scaffold "Data Source=localhost;Initial Catalog=AgentFramework;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;" Microsoft.EntityFrameworkCore.SqlServer -t WeatherForecastView -c WeatherChannelContext -f -o WebApi ``` 4. Create an initial migration ``` - dotnet ef migrations add InitialCreate --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context SemanticKernelContext + dotnet ef migrations add InitialCreate --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context AgentFrameworkContext ``` 5. Develop new entities and configurations 6. When ready to deploy new entities and configurations ``` - dotnet ef database update --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context SemanticKernelContext --connection "Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=SemanticKernel;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30" + dotnet ef database update --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context AgentFrameworkContext --connection "Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=AgentFramework;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30" ``` 7. When an entity changes, is created or deleted, create a new migration. Suggest doing this each new version. ``` - dotnet ef migrations add v1.1.1 --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context SemanticKernelContext + dotnet ef migrations add v1.1.1 --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context AgentFrameworkContext ``` # Running the Application diff --git a/src/Core.Application/Abstractions/ISemanticKernelContext.cs b/src/Core.Application/Abstractions/IAgentFrameworkContext.cs similarity index 100% rename from src/Core.Application/Abstractions/ISemanticKernelContext.cs rename to src/Core.Application/Abstractions/IAgentFrameworkContext.cs diff --git a/src/Goodtocode.AgentFramework.WebApi.sln b/src/Goodtocode.AgentFramework.WebApi.sln index d41d256..fe68975 100644 --- a/src/Goodtocode.AgentFramework.WebApi.sln +++ b/src/Goodtocode.AgentFramework.WebApi.sln @@ -11,7 +11,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Presentation.WebApi", "Pres EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.SqlServer", "Infrastructure.SqlServer\Infrastructure.SqlServer.csproj", "{615694FE-4180-4F1F-9ED7-984BA2DFCEA7}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.SemanticKernel", "Infrastructure.SemanticKernel\Infrastructure.SemanticKernel.csproj", "{2091EF1C-2E75-488C-A822-9628E639FB98}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.AgentFramework", "Infrastructure.AgentFramework\Infrastructure.AgentFramework.csproj", "{2091EF1C-2E75-488C-A822-9628E639FB98}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests.Specs.Integration", "Tests.Specs.Integration\Tests.Specs.Integration.csproj", "{5DD2A9DC-4CE9-4E47-BE7D-7EEF208D287F}" EndProject diff --git a/src/Infrastructure.SqlServer/Migrations/SemanticKernelContextModelSnapshot.cs b/src/Infrastructure.SqlServer/Migrations/AgentFrameworkContextModelSnapshot.cs similarity index 100% rename from src/Infrastructure.SqlServer/Migrations/SemanticKernelContextModelSnapshot.cs rename to src/Infrastructure.SqlServer/Migrations/AgentFrameworkContextModelSnapshot.cs diff --git a/src/Infrastructure.SqlServer/Persistence/SemanticKernelContext.cs b/src/Infrastructure.SqlServer/Persistence/AgentFrameworkContext.cs similarity index 100% rename from src/Infrastructure.SqlServer/Persistence/SemanticKernelContext.cs rename to src/Infrastructure.SqlServer/Persistence/AgentFrameworkContext.cs