SimpleMessagingService

Log | Files | Refs | README

commit a56dd431cee15ceb6e134a62151561b5a8aa47be
parent 0ba9b672703bb8356a42a52d9e6fcee0c51a9de1
Author: William Lindholm <william_lindholm@outlook.com>
Date:   Sun, 14 Jul 2024 02:30:20 +0200

Add project files.

Diffstat:
A.dockerignore | 31+++++++++++++++++++++++++++++++
AApi/Api.csproj | 27+++++++++++++++++++++++++++
AApi/Api.http | 6++++++
AApi/Controllers/MessageController.cs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AApi/Controllers/UserController.cs | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AApi/Data/AppDbContext.cs | 13+++++++++++++
AApi/Migrations/20240713180846_initial.Designer.cs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AApi/Migrations/20240713180846_initial.cs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AApi/Migrations/AppDbContextModelSnapshot.cs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AApi/Models/Message.cs | 21+++++++++++++++++++++
AApi/Models/User.cs | 19+++++++++++++++++++
AApi/Program.cs | 33+++++++++++++++++++++++++++++++++
AApi/Properties/launchSettings.json | 41+++++++++++++++++++++++++++++++++++++++++
AApi/appsettings.Development.json | 8++++++++
AApi/appsettings.json | 13+++++++++++++
AClient/AddServerWindow.xaml | 32++++++++++++++++++++++++++++++++
AClient/AddServerWindow.xaml.cs | 29+++++++++++++++++++++++++++++
AClient/App.xaml | 10++++++++++
AClient/App.xaml.cs | 14++++++++++++++
AClient/AssemblyInfo.cs | 10++++++++++
AClient/Client.csproj | 19+++++++++++++++++++
AClient/MainWindow.xaml | 49+++++++++++++++++++++++++++++++++++++++++++++++++
AClient/MainWindow.xaml.cs | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AClient/Utilities/ApiInterface.cs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AClient/Utilities/SettingsInterface.cs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AShared/Contracts/GetMessagesContract.cs | 18++++++++++++++++++
AShared/Contracts/GetUsersContract.cs | 16++++++++++++++++
AShared/Contracts/MessageContract.cs | 8++++++++
AShared/Contracts/NewUserContract.cs | 9+++++++++
AShared/Contracts/UserContract.cs | 7+++++++
AShared/Endpoints.cs | 10++++++++++
AShared/Shared.projitems | 20++++++++++++++++++++
AShared/Shared.shproj | 13+++++++++++++
ASimpleMessagingService.sln | 38++++++++++++++++++++++++++++++++++++++
34 files changed, 1319 insertions(+), 0 deletions(-)

diff --git a/.dockerignore b/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** +\ No newline at end of file diff --git a/Api/Api.csproj b/Api/Api.csproj @@ -0,0 +1,27 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7" /> + <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.7" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.7"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.3" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> + </ItemGroup> + + <Import Project="..\Shared\Shared.projitems" Label="Shared" /> + +</Project> diff --git a/Api/Api.http b/Api/Api.http @@ -0,0 +1,6 @@ +@Api_HostAddress = http://localhost:5005 + +GET {{Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Api/Controllers/MessageController.cs b/Api/Controllers/MessageController.cs @@ -0,0 +1,93 @@ +using Api.Data; +using Api.Models; +using Microsoft.AspNetCore.Mvc; +using Shared; +using Shared.Contracts; + +namespace Api.Controllers +{ + [Route(Endpoints.Messages)] + [ApiController] + public class MessageController : ControllerBase + { + private readonly AppDbContext _context; + + public MessageController(AppDbContext context) + { + _context = context; + } + + [HttpGet] + public IActionResult Get() + { + var messageDataList = _context.Messages + .Select(x => new MessageData() + { + Sender = x.Name, + Content = x.Content, + TimeStamp = x.TimeStamp, + Id = x.Id, + }) + .ToList() + .OrderBy(x => x.TimeStamp) + .ToList(); + + var messageContract = new GetMessagesContract + { + messages = messageDataList + }; + + return Ok(messageContract); + } + + [HttpPost] + public IActionResult Post([FromBody] MessageContract messageContract) + { + var key = messageContract.Key; + + if (key == Guid.Empty) + { + return BadRequest("Key is required"); + } + + var tes = _context.Users.ToList(); + + var test = _context.Users.FirstOrDefault(x => x.Key == key); + + if (!_context.Users.Any(x => x.Key == key)) + { + return BadRequest("Bad key"); + } + + var user = _context.Users.First(x => x.Key == key); + + var message = new Message + { + Content = messageContract.Content, + Name = user.Name + }; + + if (message.Content.Length > 500 && message.Content.Length == 0) + { + return BadRequest("Message must be between 1 - 500 characters"); + } + + _context.Messages.Add(message); + _context.SaveChanges(); + return Ok($"Created message {message.Id}"); + } + + [HttpDelete("{id}")] + public IActionResult Delete(Guid id) + { + var message = _context.Messages.Find(id); + + if (message == null) { + return NotFound($"Could not find message {id}"); + } + + _context.Messages.Remove(message); + return Ok($"Removed message {id}"); + } + } +} +\ No newline at end of file diff --git a/Api/Controllers/UserController.cs b/Api/Controllers/UserController.cs @@ -0,0 +1,105 @@ +using Api.Data; +using Api.Models; +using Microsoft.AspNetCore.Mvc; +using Shared; +using Shared.Contracts; + +namespace Api.Controllers; + +[Route(Endpoints.Users)] +[ApiController] +public class UserController : ControllerBase +{ + private readonly AppDbContext _context; + + public UserController(AppDbContext context) + { + _context = context; + } + + [HttpGet] + public IActionResult Get() + { + var userDataList = _context.Users + .Select(user => new UserData + { + Username = user.Name, + CreatedAt = user.CreatedAt + }) + .ToList(); + + var userContract = new GetUsersContract { users = userDataList }; + + return Ok(userContract); + } + + [HttpGet] + [Route("{id}")] + public IActionResult Get(Guid id) + { + var user = _context.Users.Find(id); + if (user == null) + { + return NotFound($"Could not find user {id}"); + } + + return Ok(user); + } + + [HttpPost] + public IActionResult Post([FromBody] UserContract userContract) + { + var baseUsername = userContract.Username; + string preferredName; + var attempts = 0; + Random random = new Random(); + HashSet<string> triedUsernames = new HashSet<string>(); + + do + { + var randomNumber = random.Next(0, 9999); + preferredName = $"{baseUsername}#{randomNumber:D4}"; + + if (triedUsernames.Contains(preferredName)) + { + continue; + } + + attempts++; + triedUsernames.Add(preferredName); + + if (attempts > 9999) + { + return BadRequest("Username already taken"); + } + + } while (_context.Users.Any(user => user.Name == preferredName)); + + var user = new User { Name = preferredName }; + + _context.Users.Add(user); + _context.SaveChanges(); + + NewUserContract newUserContract = new NewUserContract + { + Name = user.Name, + Key = user.Key, + CreatedAt = user.CreatedAt + }; + + return Ok(newUserContract); + } + + [HttpDelete("{id}")] + public IActionResult Delete(Guid id) + { + var user = _context.Users.Find(id); + if (user == null) + { + return NotFound($"Could not find user {id}"); + } + + _context.Users.Remove(user); + return Ok($"Removed user {id}"); + } +} +\ No newline at end of file diff --git a/Api/Data/AppDbContext.cs b/Api/Data/AppDbContext.cs @@ -0,0 +1,12 @@ +using Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace Api.Data; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } + + public DbSet<Message> Messages { get; set; } + public DbSet<User> Users { get; set; } +} +\ No newline at end of file diff --git a/Api/Migrations/20240713180846_initial.Designer.cs b/Api/Migrations/20240713180846_initial.Designer.cs @@ -0,0 +1,87 @@ +// <auto-generated /> +using System; +using Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20240713180846_initial")] + partial class initial + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Api.Models.Message", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property<string>("Content") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property<DateTime>("TimeStamp") + .HasColumnType("datetime2"); + + b.Property<string>("UserName") + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("UserName"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Api.Models.User", b => + { + b.Property<string>("Name") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("datetime2"); + + b.Property<Guid>("Key") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Name"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Api.Models.Message", b => + { + b.HasOne("Api.Models.User", null) + .WithMany("Messages") + .HasForeignKey("UserName"); + }); + + modelBuilder.Entity("Api.Models.User", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Api/Migrations/20240713180846_initial.cs b/Api/Migrations/20240713180846_initial.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Api.Migrations +{ + /// <inheritdoc /> + public partial class initial : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false), + Key = table.Column<Guid>(type: "uniqueidentifier", nullable: false), + CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Name); + }); + + migrationBuilder.CreateTable( + name: "Messages", + columns: table => new + { + Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false), + TimeStamp = table.Column<DateTime>(type: "datetime2", nullable: false), + Name = table.Column<string>(type: "nvarchar(max)", nullable: false), + Content = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false), + UserName = table.Column<string>(type: "nvarchar(100)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Messages", x => x.Id); + table.ForeignKey( + name: "FK_Messages_Users_UserName", + column: x => x.UserName, + principalTable: "Users", + principalColumn: "Name"); + }); + + migrationBuilder.CreateIndex( + name: "IX_Messages_UserName", + table: "Messages", + column: "UserName"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Messages"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/Api/Migrations/AppDbContextModelSnapshot.cs b/Api/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,84 @@ +// <auto-generated /> +using System; +using Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Api.Models.Message", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property<string>("Content") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property<DateTime>("TimeStamp") + .HasColumnType("datetime2"); + + b.Property<string>("UserName") + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("UserName"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Api.Models.User", b => + { + b.Property<string>("Name") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("datetime2"); + + b.Property<Guid>("Key") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Name"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Api.Models.Message", b => + { + b.HasOne("Api.Models.User", null) + .WithMany("Messages") + .HasForeignKey("UserName"); + }); + + modelBuilder.Entity("Api.Models.User", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Api/Models/Message.cs b/Api/Models/Message.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Api.Models; + +public class Message +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + public DateTime TimeStamp { get; set; } = DateTime.Now; + + [Required] + [ForeignKey("User")] + public string Name { get; set; } = String.Empty; + + [Required] + [MaxLength(500)] + public string Content { get; set; } = String.Empty; +} +\ No newline at end of file diff --git a/Api/Models/User.cs b/Api/Models/User.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Api.Models; + +public class User +{ + [Key] + [MaxLength(100)] + [MinLength(3)] + public string Name { get; set; } = string.Empty; + + [Required] + public Guid Key { get; set; } = Guid.NewGuid(); + + public DateTime CreatedAt { get; set; } = DateTime.Now; + + public ICollection<Message> Messages { get; set; } = new List<Message>(); +} +\ No newline at end of file diff --git a/Api/Program.cs b/Api/Program.cs @@ -0,0 +1,32 @@ +using Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContext<AppDbContext>(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); +\ No newline at end of file diff --git a/Api/Properties/launchSettings.json b/Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:10924", + "sslPort": 44310 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5005", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7161;http://localhost:5005", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Api/appsettings.Development.json b/Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Api/appsettings.json b/Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=MyDatabase;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} +\ No newline at end of file diff --git a/Client/AddServerWindow.xaml b/Client/AddServerWindow.xaml @@ -0,0 +1,31 @@ +<Window x:Class="Client.AddServerWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + Title="Add Server" Height="200" Width="400"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + <RowDefinition Height="Auto"/> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto"/> + <ColumnDefinition Width="*"/> + </Grid.ColumnDefinitions> + + <TextBlock Text="Name:" Margin="10" VerticalAlignment="Center" Grid.Row="0" Grid.Column="0"/> + <TextBox x:Name="NameTextBox" Margin="10" Grid.Row="0" Grid.Column="1"/> + + <TextBlock Text="URL:" Margin="10" VerticalAlignment="Center" Grid.Row="1" Grid.Column="0"/> + <TextBox x:Name="UrlTextBox" Margin="10" Grid.Row="1" Grid.Column="1"/> + + <TextBlock Text="Username:" Margin="10" VerticalAlignment="Center" Grid.Row="2" Grid.Column="0"/> + <TextBox x:Name="UsernameTextBox" Margin="10" Grid.Row="2" Grid.Column="1"/> + + <StackPanel Orientation="Horizontal" Grid.Row="3" Grid.ColumnSpan="2" HorizontalAlignment="Right" Margin="10"> + <Button Content="OK" Width="75" Margin="5" Click="OkButton_Click"/> + <Button Content="Cancel" Width="75" Margin="5" Click="CancelButton_Click"/> + </StackPanel> + </Grid> +</Window> +\ No newline at end of file diff --git a/Client/AddServerWindow.xaml.cs b/Client/AddServerWindow.xaml.cs @@ -0,0 +1,28 @@ +using System.Windows; + +namespace Client; + +public partial class AddServerWindow : Window +{ + public string? ServerName { get; private set; } + public string? ServerUrl { get; private set; } + public string? Username { get; private set; } + + public AddServerWindow() + { + InitializeComponent(); + } + + private void OkButton_Click(object sender, RoutedEventArgs e) + { + ServerName = NameTextBox.Text; + ServerUrl = UrlTextBox.Text; + Username = UsernameTextBox.Text; + DialogResult = true; + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + DialogResult = false; + } +} +\ No newline at end of file diff --git a/Client/App.xaml b/Client/App.xaml @@ -0,0 +1,9 @@ +<Application x:Class="Client.App" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="clr-namespace:Client" + StartupUri="MainWindow.xaml"> + <Application.Resources> + + </Application.Resources> +</Application> +\ No newline at end of file diff --git a/Client/App.xaml.cs b/Client/App.xaml.cs @@ -0,0 +1,14 @@ +using System.Configuration; +using System.Data; +using System.Windows; + +namespace Client +{ + /// <summary> + /// Interaction logic for App.xaml + /// </summary> + public partial class App : Application + { + } + +} diff --git a/Client/AssemblyInfo.cs b/Client/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/Client/Client.csproj b/Client/Client.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>WinExe</OutputType> + <TargetFramework>net8.0-windows</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <UseWPF>true</UseWPF> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Net.Http" Version="2.2.29" /> + <PackageReference Include="Microsoft.Net.Http.Headers" Version="8.0.7" /> + <PackageReference Include="System.Net.Http" Version="4.3.4" /> + </ItemGroup> + + <Import Project="..\Shared\Shared.projitems" Label="Shared" /> + +</Project> diff --git a/Client/MainWindow.xaml b/Client/MainWindow.xaml @@ -0,0 +1,48 @@ +<Window x:Class="Client.MainWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:local="clr-namespace:Client" + mc:Ignorable="d" + Title="MainWindow" Height="450" Width="800" + Loaded="Window_Loaded"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="200" /> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + + <Menu Grid.Row="0" Grid.ColumnSpan="3" VerticalAlignment="Top"> + <MenuItem x:Name="ServerMenu" Header="_Server"> + <MenuItem Header="_Add" Click="Server_Add_Click"/> + <MenuItem Header="_Remove" Click="Server_Remove_Click"/> + <Separator /> + </MenuItem> + <MenuItem Header="_User"> + <MenuItem Header="Add"/> + <Separator /> + </MenuItem> + <MenuItem Header="_Settings"> + <MenuItem Header="_Polling Rate"> + <MenuItem Header="_1s"/> + <MenuItem Header="_3s"/> + <MenuItem Header="_5s"/> + </MenuItem> + </MenuItem> + </Menu> + + <TextBlock Text="LindholmLabs messenger" Name="MainHeading" FontSize="24" Margin="10" Grid.Row="1" Grid.ColumnSpan="3" VerticalAlignment="Center" /> + <ListBox x:Name="UserList" Grid.Row="2" Grid.Column="0" Margin="10" /> + <ListBox x:Name="MessageList" Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2" Margin="10"/> + <TextBox x:Name="MessageInput" Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2" Margin="10,0,10,10" MinHeight="20" MaxHeight="60" TextWrapping="Wrap" /> + <Button Content="Send" Grid.Row="3" Grid.Column="2" Margin="10,0,10,10" Click="SendButton_Click" Width="60" /> + </Grid> +</Window> +\ No newline at end of file diff --git a/Client/MainWindow.xaml.cs b/Client/MainWindow.xaml.cs @@ -0,0 +1,221 @@ +using Client.Utilities; +using Shared.Contracts; +using System.Linq.Expressions; +using System.Net; +using System.Text.Json; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Threading; + +namespace Client; + +public partial class MainWindow : Window +{ + private IApiInterface _apiInterface; + private ISettingsInterface _settingsInterface = new SettingsInterface(); + private Settings _settings { get; set; } + private DispatcherTimer _timer; + + private Server? _selectedServer => _settings?.Servers?.FirstOrDefault(x => x.serverId == _settings.SelectedServer); + + public MainWindow() + { + InitializeComponent(); + } + + private async void Window_Loaded(object sender, RoutedEventArgs e) + { + await LoadSettings(); + + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(_settings.PollingInterval) }; + _timer.Tick += PollingCallback; + _timer.Start(); + } + + private async void PollingCallback(object sender, EventArgs e) + { + await LoadMessages(); + await LoadUsers(); + } + + private async Task LoadSettings() + { + _settings = await _settingsInterface.LoadSettingsAsync<Settings>(); + + if (_settings == null) + { + _settings = new Settings(); + await _settingsInterface.SaveSettingsAsync(_settings); + } + + await LoadServers(); + } + + private async Task LoadServers() + { + var itemsToRemove = new List<MenuItem>(); + + foreach (var item in ServerMenu.Items) + { + if (item is MenuItem menuItem && menuItem.Name == "server_item") + { + itemsToRemove.Add(menuItem); + } + } + + foreach (var item in itemsToRemove) + { + ServerMenu.Items.Remove(item); + } + + foreach (var server in _settings.Servers) + { + var newServerItem = new MenuItem { Header = server.Name, Name = "server_item", IsChecked = server.serverId == _settings.SelectedServer ? true : false }; + newServerItem.Click += (s, args) => ConnectToServer(server.serverId); + ServerMenu.Items.Insert(ServerMenu.Items.Count, newServerItem); + } + + + var selectedServerUrl = _settings.Servers.FirstOrDefault(s => s.serverId == _settings.SelectedServer)?.Url; + if (selectedServerUrl != null) + { + MainHeading.Text = $"Connecting to {_selectedServer.Name}"; + _apiInterface = new ApiInterface(selectedServerUrl); + + Register(_selectedServer.User); + _apiInterface.ServerKey = (Guid)_selectedServer.Key; + await LoadMessages(); + await LoadUsers(); + } + } + + private async Task LoadMessages() + { + var messages = await _apiInterface.GetMessagesAsync(); + + Dispatcher.Invoke(() => MessageList.Items.Clear()); + + if (messages?.messages == null) + { + return; + } + + Dispatcher.Invoke(() => MainHeading.Text = $"Connected to {_selectedServer.Name}"); + + foreach (var message in messages?.messages) + { + var timeStamp = message.TimeStamp.ToString("g"); + Dispatcher.Invoke(() => MessageList.Items.Add($"{timeStamp} - {message.Sender}: {message.Content}")); + } + } + + private async Task LoadUsers() + { + var users = await _apiInterface.GetUsersAsync(); + Dispatcher.Invoke(() => UserList.Items.Clear()); + + if (users?.users == null) + { + return; + } + + foreach (var user in users?.users) + { + Dispatcher.Invoke(() => UserList.Items.Add(user.Username)); + } + } + + private async void Server_Add_Click(object sender, RoutedEventArgs e) + { + AddServerWindow addServerWindow = new AddServerWindow(); + if (addServerWindow.ShowDialog() == true) + { + string serverName = addServerWindow.ServerName; + string username = addServerWindow.Username; + Uri serverUri; + try + { + serverUri = new Uri(addServerWindow.ServerUrl); + } + catch (UriFormatException) + { + MessageBox.Show("Invalid Url"); + return; + } + + var newServer = new Server { Name = serverName, User = username, Url = serverUri }; + _settings.Servers.Add(newServer); + _settings.SelectedServer = newServer.serverId; + await _settingsInterface.SaveSettingsAsync(_settings); + await LoadServers(); + } + } + + private async void ConnectToServer(Guid key) + { + Dispatcher.Invoke(() => UserList.Items.Clear()); + Dispatcher.Invoke(() => MessageList.Items.Clear()); + _settings.SelectedServer = key; + await _settingsInterface.SaveSettingsAsync(_settings); + await LoadServers(); + var server = _settings.Servers.FirstOrDefault(s => s.serverId == key); + } + + private async void Register(string newUserName) + { + if (_selectedServer?.Key != null && _selectedServer?.Key != Guid.Empty) + { + return; + } + + var response = await _apiInterface.PostUserAsync(newUserName); + if (response == null || !response.IsSuccessStatusCode) + { + MessageBox.Show("Failed to register"); + return; + } + else + { + var test = await response.Content.ReadAsStringAsync(); + var newUserContract = JsonSerializer.Deserialize<NewUserContract>(await response.Content.ReadAsStringAsync(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + var index = _settings.Servers.FindIndex(x => x.serverId == _selectedServer.serverId); + _settings.Servers[index].Key = newUserContract?.Key; + await _settingsInterface.SaveSettingsAsync(_settings); + } + } + + private async void SendButton_Click(object sender, RoutedEventArgs e) + { + var response = await _apiInterface.PostMessageAsync(MessageInput.Text); + if (!response.IsSuccessStatusCode) + { + var errorMessage = await response.Content.ReadAsStringAsync(); + switch (response.StatusCode) + { + case HttpStatusCode.BadRequest: + MessageBox.Show($"Bad request.\n{errorMessage}"); + break; + case HttpStatusCode.InternalServerError: + MessageBox.Show("Internal server error"); + break; + default: + MessageBox.Show("Unknown error"); + break; + } + } + else + { + MessageInput.Text = ""; + await LoadMessages(); + } + } + + private void Server_Remove_Click(object sender, RoutedEventArgs e) + { + var index = _settings.Servers.FindIndex(x => x.serverId == _settings.SelectedServer); + _settings.Servers.RemoveAt(index); + _settings.SelectedServer = _settings.Servers.FirstOrDefault()?.serverId; + _settingsInterface.SaveSettingsAsync(_settings); + LoadSettings(); + } +} +\ No newline at end of file diff --git a/Client/Utilities/ApiInterface.cs b/Client/Utilities/ApiInterface.cs @@ -0,0 +1,90 @@ +using Shared.Contracts; +using System.Diagnostics; +using System.Net.Http; +using System.Text.Json; + +namespace Client.Utilities; + +public interface IApiInterface +{ + Task<GetMessagesContract?> GetMessagesAsync(); + Task<GetUsersContract?> GetUsersAsync(); + Task<HttpResponseMessage?> PostMessageAsync(string message); + Task<HttpResponseMessage> PostUserAsync(string name); + Guid ServerKey { get; set; } +} + +public class ApiInterface : IApiInterface +{ + private readonly HttpClient _httpClient; + public Guid ServerKey { get; set; } = Guid.Empty; + + public ApiInterface(Uri baseUrl) + { + _httpClient = new HttpClient(); + _httpClient.BaseAddress = baseUrl; + } + + public async Task<GetMessagesContract?> GetMessagesAsync() + { + return await getContract<GetMessagesContract>(Shared.Endpoints.Messages); + } + + public async Task<GetUsersContract?> GetUsersAsync() + { + return await getContract<GetUsersContract>(Shared.Endpoints.Users); + } + + public async Task<HttpResponseMessage?> PostMessageAsync(string message) + { + var messageContract = new MessageContract { Content = message, Key = ServerKey }; + return await postContract(messageContract, Shared.Endpoints.Messages); + } + + public async Task<HttpResponseMessage?> PostUserAsync(string name) + { + var userContract = new UserContract { Username = name }; + return await postContract(userContract, Shared.Endpoints.Users); + } + + private async Task<HttpResponseMessage> postContract<T>(T content, string endpoint) + { + try + { + var json = JsonSerializer.Serialize(content); + HttpContent httpContent = new StringContent(json); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + var response = await _httpClient.PostAsync(endpoint, httpContent); + Debug.WriteLine(await response.Content.ReadAsStringAsync()); + return response; + } + catch (Exception e) + { + Debug.WriteLine(e.Message); + return default; + } + } + + private async Task<T?> getContract<T>(string endpoint) + { + int attempts = 0; + do + { + try + { + var response = await _httpClient.GetAsync(endpoint); + var responseContent = await response.Content.ReadAsStringAsync(); + Debug.WriteLine(responseContent); + return JsonSerializer.Deserialize<T>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (Exception e) + { + attempts++; + Debug.WriteLine($"Failed after {attempts} tries.\n{e.Message}"); + } + } + while (attempts < 10); + + return default; + } +} +\ No newline at end of file diff --git a/Client/Utilities/SettingsInterface.cs b/Client/Utilities/SettingsInterface.cs @@ -0,0 +1,57 @@ +using System.Diagnostics; + +namespace Client.Utilities; + +public interface ISettingsInterface +{ + Task<T?> LoadSettingsAsync<T>(); + Task SaveSettingsAsync<T>(T settings); +} + +public class SettingsInterface : ISettingsInterface +{ + private readonly string _settingsPath = "settings.json"; + + public async Task<T?> LoadSettingsAsync<T>() + { + try + { + var json = await System.IO.File.ReadAllTextAsync(_settingsPath); + return System.Text.Json.JsonSerializer.Deserialize<T>(json); + } + catch (Exception e) + { + Debug.WriteLine(e.Message); + return default; + } + } + + public async Task SaveSettingsAsync<T>(T settings) + { + try + { + var json = System.Text.Json.JsonSerializer.Serialize(settings); + await System.IO.File.WriteAllTextAsync(_settingsPath, json); + } + catch (Exception e) + { + Debug.WriteLine(e.Message); + } + } +} + +public class Settings +{ + public List<Server> Servers { get; set; } = new List<Server>(); + public Guid? SelectedServer { get; set; } + public int PollingInterval { get; set; } = 1000; +} + +public class Server +{ + public Guid serverId { get; set; } = Guid.NewGuid(); + public string? Name { get; set; } + public string? User { get; set; } + public Guid? Key { get; set; } + public Uri? Url { get; set; } +} +\ No newline at end of file diff --git a/Shared/Contracts/GetMessagesContract.cs b/Shared/Contracts/GetMessagesContract.cs @@ -0,0 +1,17 @@ +namespace Shared.Contracts; + +public class GetMessagesContract +{ + public DateTime createdAt { get; set; } = DateTime.Now; + public string createdBy { get; set; } = Environment.MachineName; + public string origin { get; set; } = string.Empty; + public List<MessageData> messages { get; set; } = new List<MessageData>(); +} + +public class MessageData +{ + public Guid Id { get; set; } = Guid.Empty; + public string Sender { get; set; } = string.Empty; + public DateTime TimeStamp { get; set; } = DateTime.Now; + public string Content { get; set; } = string.Empty; +} +\ No newline at end of file diff --git a/Shared/Contracts/GetUsersContract.cs b/Shared/Contracts/GetUsersContract.cs @@ -0,0 +1,15 @@ +namespace Shared.Contracts; + +public class GetUsersContract +{ + public DateTime createdAt { get; set; } = DateTime.Now; + public string createdBy { get; set; } = Environment.MachineName; + public string origin { get; set; } = string.Empty; + public List<UserData> users { get; set; } = new List<UserData>(); +} + +public class UserData +{ + public string Username { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.Now; +} +\ No newline at end of file diff --git a/Shared/Contracts/MessageContract.cs b/Shared/Contracts/MessageContract.cs @@ -0,0 +1,7 @@ +namespace Shared.Contracts; + +public class MessageContract +{ + public Guid Key { get; set; } = Guid.Empty; + public string Content { get; set; } = string.Empty; +} +\ No newline at end of file diff --git a/Shared/Contracts/NewUserContract.cs b/Shared/Contracts/NewUserContract.cs @@ -0,0 +1,8 @@ +namespace Shared.Contracts; + +public class NewUserContract +{ + public string Name { get; set; } = string.Empty; + public Guid Key { get; set; } + public DateTime CreatedAt { get; set; } +} +\ No newline at end of file diff --git a/Shared/Contracts/UserContract.cs b/Shared/Contracts/UserContract.cs @@ -0,0 +1,6 @@ +namespace Shared.Contracts; + +public class UserContract +{ + public string Username { get; set; } = string.Empty; +} +\ No newline at end of file diff --git a/Shared/Endpoints.cs b/Shared/Endpoints.cs @@ -0,0 +1,9 @@ +namespace Shared +{ + public class Endpoints + { + public const string BaseUrl = "https://localhost:7161"; + public const string Messages = "/messages"; + public const string Users = "/users"; + } +} +\ No newline at end of file diff --git a/Shared/Shared.projitems b/Shared/Shared.projitems @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <PropertyGroup> + <MSBuildAllProjects Condition="'$(MSBuildVersion)' == '' Or '$(MSBuildVersion)' &lt; '16.0'">$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects> + <HasSharedItems>true</HasSharedItems> + <SharedGUID>68156305-66a9-4dc3-b868-dc9c0b8bd678</SharedGUID> + </PropertyGroup> + <PropertyGroup Label="Configuration"> + <Import_RootNamespace>Shared</Import_RootNamespace> + </PropertyGroup> + <ItemGroup> + <Compile Include="$(MSBuildThisFileDirectory)contracts\NewUserContract.cs" /> + <Compile Include="..\Shared\contracts\MessageContract.cs" /> + <Compile Include="..\Shared\contracts\UserContract.cs" /> + <Compile Include="..\Shared\contracts\GetMessagesContract.cs" /> + <Compile Include="..\Shared\contracts\GetUsersContract.cs" /> + <Compile Include="$(MSBuildThisFileDirectory)Endpoints.cs" /> + </ItemGroup> +</Project> +\ No newline at end of file diff --git a/Shared/Shared.shproj b/Shared/Shared.shproj @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <PropertyGroup Label="Globals"> + <ProjectGuid>68156305-66a9-4dc3-b868-dc9c0b8bd678</ProjectGuid> + <MinimumVisualStudioVersion>14.0</MinimumVisualStudioVersion> + </PropertyGroup> + <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> + <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.Default.props" /> + <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.props" /> + <PropertyGroup /> + <Import Project="Shared.projitems" Label="Shared" /> + <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.CSharp.targets" /> +</Project> diff --git a/SimpleMessagingService.sln b/SimpleMessagingService.sln @@ -0,0 +1,38 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34728.123 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client", "Client\Client.csproj", "{30999208-C7FB-4D21-9C8E-28C78483121F}" +EndProject +Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "Shared", "Shared\Shared.shproj", "{68156305-66A9-4DC3-B868-DC9C0B8BD678}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "Api\Api.csproj", "{CF2D14EC-A094-4499-A70B-86D8B168C07D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {30999208-C7FB-4D21-9C8E-28C78483121F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30999208-C7FB-4D21-9C8E-28C78483121F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30999208-C7FB-4D21-9C8E-28C78483121F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30999208-C7FB-4D21-9C8E-28C78483121F}.Release|Any CPU.Build.0 = Release|Any CPU + {CF2D14EC-A094-4499-A70B-86D8B168C07D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF2D14EC-A094-4499-A70B-86D8B168C07D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF2D14EC-A094-4499-A70B-86D8B168C07D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF2D14EC-A094-4499-A70B-86D8B168C07D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5A133971-BAFA-4E81-8CAE-B69ECFBA20EA} + EndGlobalSection + GlobalSection(SharedMSBuildProjectFiles) = preSolution + Shared\Shared.projitems*{30999208-c7fb-4d21-9c8e-28c78483121f}*SharedItemsImports = 5 + Shared\Shared.projitems*{68156305-66a9-4dc3-b868-dc9c0b8bd678}*SharedItemsImports = 13 + Shared\Shared.projitems*{cf2d14ec-a094-4499-a70b-86d8b168c07d}*SharedItemsImports = 5 + EndGlobalSection +EndGlobal