Initial commit carried over from private repo. This is V2.
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m3s
Build and Push Docker Image / docker (push) Successful in 43s

This commit is contained in:
2025-07-04 21:24:12 +02:00
parent 7715816029
commit 4393977389
96 changed files with 3223 additions and 0 deletions

View File

@ -0,0 +1,9 @@
namespace MessengerApi.Configuration.Enums
{
public enum HousekeepingMessageStates
{
None,
Delivered,
Acknowledged
}
}

View File

@ -0,0 +1,9 @@
namespace MessengerApi.Configuration.Enums
{
public enum LoggingVerbosity
{
Normal,
Debug,
Trace
}
}

View File

@ -0,0 +1,8 @@
namespace MessengerApi.Configuration.Enums
{
public enum PersistenceTypes
{
Sql,
PostgreSql
}
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,98 @@
using MessengerApi.Configuration.Enums;
using MessengerApi.Configuration.Model.Persistence.Base;
using MessengerApi.Configuration.Parsers;
using MessengerApi.Configuration.Sources.Environment;
using Env = MessengerApi.Configuration.Constants.EnvironmentVariables;
namespace MessengerApi.Configuration.Model
{
public class MessengerConfiguration
{
/// <summary>
/// CORS origins.
/// </summary>
public string[] Origins { get; set; }
/// <summary>
/// List of proxies that are trusted to provide forwarding headers.
/// </summary>
public string[] Proxies { get; set; }
/// <summary>
/// Persistence layer configs (database).
/// </summary>
public PersistenceConfiguration PersistenceConfiguration { get; set; }
/// <summary>
/// Limits rate of user calls to not DoS the service.
/// </summary>
public int RateLimitPerMinute { get; set; }
/// <summary>
/// Message lifetime unless set differently in message body.
/// </summary>
public int DefaultMessageLifetimeInMinutes { get; set; }
/// <summary>
/// If true, messages are periodically wiped to free up space.
/// </summary>
public bool HousekeepingEnabled { get; set; }
/// <summary>
/// Messages older than value set will be deleted regardless of their delivery state.
/// </summary>
public int HousekeepingMessageAgeInMinutes { get; set; }
/// <summary>
/// Determines level of log messages displayed.
/// </summary>
public LoggingVerbosity Verbosity { get; set; }
/// <summary>
/// In addition to <see cref="HousekeepingMessageAgeInMinutes"/> messages of certain state can also be deleted, increasing storage efficiency.
/// </summary>
public HousekeepingMessageStates HousekeepingMessageState { get; set; }
public MessengerConfiguration() { }
public MessengerConfiguration(string[] origins, PersistenceConfiguration persistenceConfiguration)
{
if(persistenceConfiguration == null)
{
throw new ArgumentNullException(nameof(persistenceConfiguration));
}
this.PersistenceConfiguration = persistenceConfiguration;
this.Origins = origins ?? [];
this.Proxies = [];
this.RateLimitPerMinute = 120;
this.DefaultMessageLifetimeInMinutes = 1;
this.HousekeepingEnabled = true;
this.HousekeepingMessageAgeInMinutes = 120;
this.HousekeepingMessageState = HousekeepingMessageStates.None;
this.Verbosity = LoggingVerbosity.Normal;
}
public MessengerConfiguration(IEnvironmentConfigurationSource config) : this(
CorsParser.Parse(config.GetValue<string>(Env.CORS_ORIGINS)),
EnvironmentPersistenceConfigurationParser.Parse(config))
{
Populate<string>(config, Env.PROXIES, x => this.Proxies = ProxiesParser.Parse(x));
Populate<int>(config, Env.QUERY_RATE_PER_MINUTE, x => this.RateLimitPerMinute = x);
Populate<int>(config, Env.DEFAULT_MESSAGE_LIFETIME_IN_MINUTES, x => this.DefaultMessageLifetimeInMinutes = x);
Populate<bool>(config, Env.HOUSEKEEPING_ENABLED, x => this.HousekeepingEnabled = x);
Populate<int>(config, Env.HOUSEKEEPING_MESSAGE_AGE_IN_MINUTES, x => this.HousekeepingMessageAgeInMinutes = x);
Populate<string>(config, Env.HOUSEKEEPING_MESSAGE_STATE, x => this.HousekeepingMessageState = HousekeepingMessageStateParser.Parse(x));
Populate<string>(config, Env.LOGGING_VERBOSITY, x => this.Verbosity = LoggingVerbosityParser.Parse(x));
void Populate<T>(IEnvironmentConfigurationSource config, string key, Action<T> set)
{
if (config.HasKey(key))
{
var value = config.GetValue<T>(key);
set(value);
}
}
}
}
}

View File

@ -0,0 +1,9 @@
using MessengerApi.Configuration.Enums;
namespace MessengerApi.Configuration.Model.Persistence.Base
{
public abstract class PersistenceConfiguration
{
public abstract PersistenceTypes PersistenceType { get; }
}
}

View File

@ -0,0 +1,20 @@
using MessengerApi.Configuration.Enums;
using MessengerApi.Configuration.Model.Persistence.Base;
using MessengerApi.Configuration.Sources.Environment;
namespace MessengerApi.Configuration.Model.Persistence
{
public class NpgPersistenceConfiguration : PersistenceConfiguration
{
public override PersistenceTypes PersistenceType => PersistenceTypes.PostgreSql;
public string ConnectionString { get; }
public NpgPersistenceConfiguration(string connectionString)
{
ConnectionString = connectionString;
}
public NpgPersistenceConfiguration(IEnvironmentConfigurationSource config) : this(config.GetValue<string>(Constants.EnvironmentVariables.NPG_CONNECTIONSTRING)) { }
}
}

View File

@ -0,0 +1,20 @@
using MessengerApi.Configuration.Enums;
using MessengerApi.Configuration.Model.Persistence.Base;
using MessengerApi.Configuration.Sources.Environment;
namespace MessengerApi.Configuration.Model.Persistence
{
public class SqlPersistenceConfiguration : PersistenceConfiguration
{
public override PersistenceTypes PersistenceType => PersistenceTypes.Sql;
public string ConnectionString { get; }
public SqlPersistenceConfiguration(string connectionString)
{
ConnectionString = connectionString;
}
public SqlPersistenceConfiguration(IEnvironmentConfigurationSource config) : this(config.GetValue<string>(Constants.EnvironmentVariables.SQL_CONNECTIONSTRING)) { }
}
}

View File

@ -0,0 +1,11 @@
namespace MessengerApi.Configuration.Parsers
{
public static class CorsParser
{
public static string[] Parse(string value)
{
if (string.IsNullOrWhiteSpace(value)) return [];
return value.Trim().Split(",", StringSplitOptions.RemoveEmptyEntries);
}
}
}

View File

@ -0,0 +1,25 @@
using MessengerApi.Configuration.Model.Persistence;
using MessengerApi.Configuration.Model.Persistence.Base;
using MessengerApi.Configuration.Sources.Environment;
namespace MessengerApi.Configuration.Parsers
{
public static class EnvironmentPersistenceConfigurationParser
{
public static PersistenceConfiguration Parse(IEnvironmentConfigurationSource config)
{
var type = PersistenceTypeParser.Parse(config.GetValue<string>(Constants.EnvironmentVariables.PERSISTENCE_TYPE));
if(type == Enums.PersistenceTypes.Sql)
{
return new SqlPersistenceConfiguration(config);
}
else if(type == Enums.PersistenceTypes.PostgreSql)
{
return new NpgPersistenceConfiguration(config);
}
throw new InvalidOperationException("Unrecognized persistence type.");
}
}
}

View File

@ -0,0 +1,12 @@
using MessengerApi.Configuration.Enums;
namespace MessengerApi.Configuration.Parsers
{
public static class HousekeepingMessageStateParser
{
public static HousekeepingMessageStates Parse(string input)
{
return (HousekeepingMessageStates)Enum.Parse(typeof(HousekeepingMessageStates), input.Trim(), true);
}
}
}

View File

@ -0,0 +1,12 @@
using MessengerApi.Configuration.Enums;
namespace MessengerApi.Configuration.Parsers
{
public static class LoggingVerbosityParser
{
public static LoggingVerbosity Parse(string value)
{
return (LoggingVerbosity)Enum.Parse(typeof(LoggingVerbosity), value.Trim(), true);
}
}
}

View File

@ -0,0 +1,12 @@
using MessengerApi.Configuration.Enums;
namespace MessengerApi.Configuration.Parsers
{
public static class PersistenceTypeParser
{
public static PersistenceTypes Parse(string value)
{
return (PersistenceTypes)Enum.Parse(typeof(PersistenceTypes), value, true);
}
}
}

View File

@ -0,0 +1,11 @@
namespace MessengerApi.Configuration.Parsers
{
public static class ProxiesParser
{
public static string[] Parse(string value)
{
if (string.IsNullOrWhiteSpace(value)) return [];
return value.Trim().Split(",", StringSplitOptions.RemoveEmptyEntries);
}
}
}

View File

@ -0,0 +1,20 @@
namespace MessengerApi.Configuration
{
public static partial class Constants
{
public static class EnvironmentVariables
{
public const string SQL_CONNECTIONSTRING = nameof(SQL_CONNECTIONSTRING);
public const string NPG_CONNECTIONSTRING = nameof(NPG_CONNECTIONSTRING);
public const string PERSISTENCE_TYPE = nameof(PERSISTENCE_TYPE);
public const string CORS_ORIGINS = nameof(CORS_ORIGINS);
public const string PROXIES = nameof(PROXIES);
public const string QUERY_RATE_PER_MINUTE = nameof(QUERY_RATE_PER_MINUTE);
public const string DEFAULT_MESSAGE_LIFETIME_IN_MINUTES = nameof(DEFAULT_MESSAGE_LIFETIME_IN_MINUTES);
public const string HOUSEKEEPING_ENABLED = nameof(HOUSEKEEPING_ENABLED);
public const string HOUSEKEEPING_MESSAGE_AGE_IN_MINUTES = nameof(HOUSEKEEPING_MESSAGE_AGE_IN_MINUTES);
public const string HOUSEKEEPING_MESSAGE_STATE = nameof(HOUSEKEEPING_MESSAGE_STATE);
public const string LOGGING_VERBOSITY = nameof(LOGGING_VERBOSITY);
}
}
}

View File

@ -0,0 +1,15 @@
namespace MessengerApi.Configuration.Sources.Environment
{
public class EnvironmentConfigurationSource : IEnvironmentConfigurationSource
{
public bool HasKey(string key)
{
return !string.IsNullOrWhiteSpace(System.Environment.GetEnvironmentVariable(key));
}
public T GetValue<T>(string key)
{
return (T)Convert.ChangeType(System.Environment.GetEnvironmentVariable(key), typeof(T));
}
}
}

View File

@ -0,0 +1,6 @@
namespace MessengerApi.Configuration.Sources.Environment
{
public interface IEnvironmentConfigurationSource : IConfigurationSource
{
}
}

View File

@ -0,0 +1,9 @@
namespace MessengerApi.Configuration.Sources
{
public interface IConfigurationSource
{
bool HasKey(string key);
T GetValue<T>(string key);
}
}

View File

@ -0,0 +1,22 @@
namespace MessengerApi.Contracts.MessageParser
{
/// <summary>
/// A tool that helps converting POCO request/response models into <see cref="InboxMessage"/>, <see cref="OutboxMessage"/> and back.
/// </summary>
/// <remarks>
/// If you implement this, it's gonna be a lot easier for you to translate
/// dumb request class into <see cref="OutboxMessage"/>, then convert
/// <see cref="InboxMessage"/> back to request at server-side, and do the
/// same with the response all the way down to the client.
/// </remarks>
public interface IMessageParser<TRequest, TResponse>
{
OutboxMessage GetMessageFromRequest(TRequest request, int targetUserId);
TRequest GetRequestFromMessage(InboxMessage message);
OutboxMessage GetMessageFromResponse(TResponse response, string apiKey, int targetUserId, InboxMessage requestOrigin = null);
TResponse GetResponseFromMessage(InboxMessage message);
}
}

View File

@ -0,0 +1,50 @@
using Newtonsoft.Json;
namespace MessengerApi.Contracts.MessageParser
{
public class MessageParser<TRequest, TResponse> : IMessageParser<TRequest, TResponse>
{
public OutboxMessage GetMessageFromRequest(
TRequest request,
int targetUserId)
{
var message = new OutboxMessage
{
}
apikey,
targetUserId,
typeof(TRequest).Name,
JsonConvert.SerializeObject(request));
return message;
}
public OutboxMessage GetMessageFromResponse(
TResponse response,
string apiKey,
int targetUserId,
InboxMessage requestOrigin = null)
{
var message = new OutboxMessage(
apiKey,
targetUserId,
requestOrigin.PayloadType,
JsonConvert.SerializeObject(response));
return message;
}
public TRequest GetRequestFromMessage(InboxMessage message)
{
var request = JsonConvert.DeserializeObject<TRequest>(message.Payload);
return request;
}
public TResponse GetResponseFromMessage(InboxMessage message)
{
var request = JsonConvert.DeserializeObject<TResponse>(message.Payload);
return request;
}
}
}

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<AssemblyVersion>$([System.DateTime]::UtcNow.ToString("yyyy.MM.dd.HHmm"))</AssemblyVersion>
<PackageVersion>$([System.DateTime]::UtcNow.ToString("yyyy.MM.dd.HHmm"))</PackageVersion>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<BaseOutputPath>..\out\</BaseOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessengerApi.Contracts" Version="2025.6.28.2202" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,30 @@
namespace MessengerApi.Contracts
{
/// <summary>
/// Exists for mocking reason. This is implemented by <see cref="MessengerClient"/>.
/// </summary>
public interface IMessengerClient
{
/// <summary>
/// Receives pending messages from the messenger API.
/// </summary>
/// <param name="credentials">Credentials to the API.</param>
IEnumerable<InboxMessage> GetMessages();
/// <summary>
/// Acknowledges message reception to the server.
/// </summary>
void AckMessage(InboxMessage message);
/// <summary>
/// Sends a message.
/// </summary>
/// <param name="credentials">Credentials to the API.</param>
void SendMessage(OutboxMessage outboxMessage);
/// <summary>
/// Returns user ids for allowed message recipients.
/// </summary>
Contact[] GetYellowPages();
}
}

View File

@ -0,0 +1,150 @@
using portaloggy;
using System.Text;
using System.Text.Json.Nodes;
namespace MessengerApi.Contracts
{
public class MessengerClient : IMessengerClient
{
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
private DateTime _lastReceivedUtc;
private Credentials _credentials;
public MessengerClient(Credentials credentials, HttpClient httpClient = null, ILogger logger = null)
{
_credentials = credentials;
_httpClient = httpClient ?? new HttpClient();
_logger = logger ?? new ConsoleLogger();
_lastReceivedUtc = DateTime.MinValue.ToUniversalTime();
}
public IEnumerable<InboxMessage> GetMessages()
{
var since = Uri.EscapeDataString(this._lastReceivedUtc.ToString("o"));
var url = $"{_credentials.ApiUrl}/receive?sinceUtc={since}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _credentials.ApiKey);
_logger.Debug($"Sending query to {url} with content {request.ToString()} to obtain messages.");
var response = _httpClient.Send(request);
if (!response.IsSuccessStatusCode)
{
_logger.Error(response.ReasonPhrase);
throw new HttpRequestException("Can't receive.", null, response.StatusCode);
}
var responseContent = response.Content
.ReadAsStringAsync()
.GetAwaiter()
.GetResult();
if (!string.IsNullOrWhiteSpace(responseContent))
{
_logger.Debug($"Received response of {responseContent}.");
}
if (string.IsNullOrWhiteSpace(responseContent))
{
return Enumerable.Empty<InboxMessage>().ToArray();
}
var json = JsonNode.Parse(responseContent);
var messages = new List<InboxMessage>();
foreach (var item in json["messages"].AsArray())
{
if (item["id"].GetValue<int>() == -1)
{
continue;
}
messages.Add(new InboxMessage
{
Id = item["id"].GetValue<Guid>(),
Payload = item["payload"].ToJsonString(),
PayloadId = item["payloadId"].ToJsonString(),
PayloadType = item["payloadType"].ToJsonString(),
Sender = item["sender"].GetValue<Guid>(),
SenderTimestamp = item["senderTimestamp"].GetValue<DateTime>()
});
}
_lastReceivedUtc = DateTime.UtcNow.Subtract(TimeSpan.FromSeconds(10));
_logger.Debug($"Received {messages.Count} messages and last check timestamp is set to {_lastReceivedUtc.ToString("s")}.");
return messages.ToArray();
}
public void SendMessage(OutboxMessage outboxMessage)
{
var body = new JsonObject();
if(outboxMessage.ToUserId.HasValue)
{
body.Add("toUserId", JsonValue.Create<Guid>(outboxMessage.ToUserId.Value));
}
if(outboxMessage.Payload != null)
{
body.Add("payload", JsonValue.Create<string>(outboxMessage.Payload));
}
var content = new StringContent(body.ToString(), Encoding.UTF8, "application/json");
var url = $"{_credentials.ApiUrl}/send";
var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _credentials.ApiKey);
_logger.Debug($"Sending query to {url} with content {body.ToString()} to obtain messages.");
var response = _httpClient.Send(request);
if (!response.IsSuccessStatusCode)
{
_logger.Error(response.ReasonPhrase);
throw new HttpRequestException("Can't send.", null, response.StatusCode);
}
}
public Contact[] GetYellowPages()
{
var url = $"{_credentials.ApiUrl}/yellowpages";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _credentials.ApiKey);
_logger.Debug($"Sending query to {url} with content {request.ToString()}.");
var response = _httpClient.Send(request);
if (!response.IsSuccessStatusCode)
{
_logger.Error(response.ReasonPhrase);
throw new HttpRequestException("Can't receive.", null, response.StatusCode);
}
var responseContent = response.Content
.ReadAsStringAsync()
.GetAwaiter()
.GetResult();
var json = JsonNode.Parse(responseContent);
var contacts = json["users"].AsArray().Select(x => new Contact
{
Id = x["id"].GetValue<Guid>(),
Name = x["name"].GetValue<string>()
}).ToArray();
return contacts;
}
public void AckMessage(InboxMessage message)
{
throw new NotImplementedException();
}
}
}

View File

@ -0,0 +1,9 @@
namespace MessengerApi.Contracts
{
public class Contact
{
public Guid Id { get; set; }
public string Name { get; set; }
}
}

View File

@ -0,0 +1,15 @@
namespace MessengerApi.Contracts
{
public class Credentials
{
public string ApiKey { get; private set; }
public string ApiUrl { get; private set; }
public Credentials(string apiKey, string apiUrl)
{
ApiKey = apiKey;
ApiUrl = apiUrl;
}
}
}

View File

@ -0,0 +1,20 @@
namespace MessengerApi.Contracts
{
/// <summary>
/// Message when received is inbox. For server apps, this is request-type of message. For clients, this is a response-type of message.
/// </summary>
public class InboxMessage
{
public Guid Id { get; set; }
public Guid Sender { get; set; }
public DateTime? SenderTimestamp { get; set; }
public string PayloadId { get; set; }
public string PayloadType { get; set; }
public string Payload { get; set; }
}
}

View File

@ -0,0 +1,20 @@
namespace MessengerApi.Contracts
{
/// <summary>
/// Outbox type of message. A server-app will treat this as a response. A client app will treat this as a request.
/// </summary>
public class OutboxMessage
{
public Guid? ToUserId { get; set; }
public string PayloadId { get; set; }
public string PayloadType { get; set; }
public string Payload { get; set; }
public DateTime? PayloadTimestamp { get; set; }
public int? PayloadLifespanInSeconds { get; set; }
}
}

View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<PackageOutputPath>$(OutputPath)</PackageOutputPath>
<AssemblyVersion>$([System.DateTime]::UtcNow.ToString("yyyy.MM.dd.HHmm"))</AssemblyVersion>
<PackageVersion>$([System.DateTime]::UtcNow.ToString("yyyy.MM.dd.HHmm"))</PackageVersion>
<BaseOutputPath>..\out\</BaseOutputPath>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="portaloggy" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,11 @@
namespace MessengerApi.Db.Contracts.Entities
{
public interface IEntity
{
Guid Id { get; }
}
public interface IEntity<T> : IEntity where T : class, IEntity
{
}
}

View File

@ -0,0 +1,25 @@
using MessengerApi.Db.Contracts.Entities;
namespace MessengerApi.Db.Entities
{
public class Message : IEntity<Message>
{
public Guid Id { get; set; }
public DateTime CreatedUtc { get; set; }
public Guid FromId { get; set; }
public Guid ToId { get; set; }
public bool IsDelivered { get; set; }
public bool IsAcknowledged { get; set; }
public string PayloadType { get; set; }
public string Payload { get; set; }
public int? PayloadLifespanInSeconds { get; set; }
}
}

View File

@ -0,0 +1,15 @@
using MessengerApi.Db.Contracts.Entities;
namespace MessengerApi.Db.Entities
{
public class User : IEntity<User>
{
public Guid Id { get; set; }
public Guid ApiKey { get; set; }
public string Name { get; set; }
public bool IsEnabled { get; set; }
}
}

View File

@ -0,0 +1,16 @@
using MessengerApi.Db.Contracts.Entities;
namespace MessengerApi.Db.Entities
{
/// <summary>
/// Describes allowed message route (who can message whom).
/// </summary>
public class UserRoute : IEntity<UserRoute>
{
public Guid Id { get; set; }
public User From { get; set; }
public User To { get; set; }
}
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,9 @@
using MessengerApi.Db.Entities;
namespace MessengerApi.Db.Contracts.Repositories
{
public interface IMessageRepository : IRepository<Message>
{
IEnumerable<Message> GetPendingMessages(User user);
}
}

View File

@ -0,0 +1,15 @@
using MessengerApi.Db.Contracts.Entities;
namespace MessengerApi.Db.Contracts.Repositories
{
public interface IRepository
{
}
public interface IRepository<T> : IRepository where T : class, IEntity<T>
{
void Add(T entity);
T GetById(Guid id);
}
}

View File

@ -0,0 +1,9 @@
using MessengerApi.Db.Entities;
namespace MessengerApi.Db.Contracts.Repositories
{
public interface IUserRepository : IRepository<User>
{
User SingleByApiKeyAndEnabled(Guid id, bool enabled);
}
}

View File

@ -0,0 +1,17 @@
using MessengerApi.Db.Entities;
namespace MessengerApi.Db.Contracts.Repositories
{
public interface IUserRouteRepository:IRepository<UserRoute>
{
/// <summary>
/// Returns all routes for given user.
/// </summary>
IEnumerable<UserRoute> GetAllByUser(User sender);
/// <summary>
/// Returns routes where given user is sender.
/// </summary>
IEnumerable<UserRoute> GetByFrom(User user);
}
}

View File

@ -0,0 +1,13 @@
using MessengerApi.Db.Npg;
using Microsoft.EntityFrameworkCore.Design;
namespace MessengerApi.Db.Sql.Migrator
{
public partial class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<MessengerNpgDbContext>
{
public MessengerNpgDbContext CreateDbContext(string[] args)
{
return new MessengerNpgDbContext(this.ConnectionString);
}
}
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Db.Npg\MessengerApi.Db.Npg.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,16 @@
namespace MessengerApi.Db.Npg.Migrator
{
internal class Program
{
static void Main(string[] args)
{
// You can use empty string to build the context when adding a migration - adding a migration does not hit the DB.
// DesignTimeDbFactory.ConnectionString.cs is not versioned on purposed. Add missing property file for this partial class and then run this command:
// Add-Migration YourMigration -Project MessengerApi.Db.Npg -StartupProject MessengerApi.Db.Npg.Migrator -Verbose -Context MessengerNpgDbContext
// To update the database, make sure your connection string is correct and run this command:
// Update-Database -Project MessengerApi.Db.Npg -StartupProject MessengerApi.Db.Npg.Migrator -Verbose -Context MessengerNpgDbContext
}
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Db\MessengerApi.Db.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,31 @@
using MessengerApi.Db.Entities;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db.Npg
{
public class MessengerNpgDbContext : MessengerDbContext
{
private readonly string connectionString;
public MessengerNpgDbContext(string connectionString)
{
this.connectionString = connectionString;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseNpgsql(this.connectionString);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// https://stackoverflow.com/questions/26464357/what-is-the-purpose-of-non-unique-indexes-in-a-database
// https://stackoverflow.com/questions/40767980/generate-a-composite-unique-constraint-index-in-ef-core
// https://www.geeksforgeeks.org/difference-between-clustered-and-non-clustered-index/
modelBuilder.Entity<Message>().HasIndex(e => new { e.ToId, e.IsDelivered }).IsUnique(false);
}
}
}

View File

@ -0,0 +1,123 @@
// <auto-generated />
using System;
using MessengerApi.Db.Npg;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MessengerApi.Db.Npg.Migrations
{
[DbContext(typeof(MessengerNpgDbContext))]
[Migration("20250704170425_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MessengerApi.Db.Entities.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromId")
.HasColumnType("uuid");
b.Property<bool>("IsAcknowledged")
.HasColumnType("boolean");
b.Property<bool>("IsDelivered")
.HasColumnType("boolean");
b.Property<string>("Payload")
.HasColumnType("text");
b.Property<int>("PayloadLifespanInSeconds")
.HasColumnType("integer");
b.Property<string>("PayloadType")
.HasColumnType("text");
b.Property<Guid>("ToId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ToId", "IsDelivered");
b.ToTable("Messages");
});
modelBuilder.Entity("MessengerApi.Db.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiKey")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("FromId")
.HasColumnType("uuid");
b.Property<Guid?>("ToId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromId");
b.HasIndex("ToId");
b.ToTable("UserRoutes");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.HasOne("MessengerApi.Db.Entities.User", "From")
.WithMany()
.HasForeignKey("FromId");
b.HasOne("MessengerApi.Db.Entities.User", "To")
.WithMany()
.HasForeignKey("ToId");
b.Navigation("From");
b.Navigation("To");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,99 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MessengerApi.Db.Npg.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Messages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
FromId = table.Column<Guid>(type: "uuid", nullable: false),
ToId = table.Column<Guid>(type: "uuid", nullable: false),
IsDelivered = table.Column<bool>(type: "boolean", nullable: false),
IsAcknowledged = table.Column<bool>(type: "boolean", nullable: false),
PayloadType = table.Column<string>(type: "text", nullable: true),
Payload = table.Column<string>(type: "text", nullable: true),
PayloadLifespanInSeconds = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Messages", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ApiKey = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserRoutes",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FromId = table.Column<Guid>(type: "uuid", nullable: true),
ToId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserRoutes", x => x.Id);
table.ForeignKey(
name: "FK_UserRoutes_Users_FromId",
column: x => x.FromId,
principalTable: "Users",
principalColumn: "Id");
table.ForeignKey(
name: "FK_UserRoutes_Users_ToId",
column: x => x.ToId,
principalTable: "Users",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Messages_ToId_IsDelivered",
table: "Messages",
columns: new[] { "ToId", "IsDelivered" });
migrationBuilder.CreateIndex(
name: "IX_UserRoutes_FromId",
table: "UserRoutes",
column: "FromId");
migrationBuilder.CreateIndex(
name: "IX_UserRoutes_ToId",
table: "UserRoutes",
column: "ToId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Messages");
migrationBuilder.DropTable(
name: "UserRoutes");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@ -0,0 +1,120 @@
// <auto-generated />
using System;
using MessengerApi.Db.Npg;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MessengerApi.Db.Npg.Migrations
{
[DbContext(typeof(MessengerNpgDbContext))]
partial class MessengerNpgDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MessengerApi.Db.Entities.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromId")
.HasColumnType("uuid");
b.Property<bool>("IsAcknowledged")
.HasColumnType("boolean");
b.Property<bool>("IsDelivered")
.HasColumnType("boolean");
b.Property<string>("Payload")
.HasColumnType("text");
b.Property<int>("PayloadLifespanInSeconds")
.HasColumnType("integer");
b.Property<string>("PayloadType")
.HasColumnType("text");
b.Property<Guid>("ToId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ToId", "IsDelivered");
b.ToTable("Messages");
});
modelBuilder.Entity("MessengerApi.Db.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiKey")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("FromId")
.HasColumnType("uuid");
b.Property<Guid?>("ToId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromId");
b.HasIndex("ToId");
b.ToTable("UserRoutes");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.HasOne("MessengerApi.Db.Entities.User", "From")
.WithMany()
.HasForeignKey("FromId");
b.HasOne("MessengerApi.Db.Entities.User", "To")
.WithMany()
.HasForeignKey("ToId");
b.Navigation("From");
b.Navigation("To");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,12 @@
using Microsoft.EntityFrameworkCore.Design;
namespace MessengerApi.Db.Sql.Migrator
{
public partial class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<MessengerSqlDbContext>
{
public MessengerSqlDbContext CreateDbContext(string[] args)
{
return new MessengerSqlDbContext(this.ConnectionString);
}
}
}

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Db.Sql\MessengerApi.Db.Sql.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,16 @@
namespace MessengerApi.Db.Sql.Migrator
{
internal class Program
{
static void Main(string[] args)
{
// You can use empty string to build the context when adding a migration - adding a migration does not hit the DB.
// DesignTimeDbFactory.ConnectionString.cs is not versioned on purposed. Add missing property file for this partial class and then run this command:
// Add-Migration YourMigration -Project MessengerApi.Db.Sql -StartupProject MessengerApi.Db.Sql.Migrator -Verbose -Context MessengerSqlDbContext
// To update the database, make sure your connection string is correct and run this command:
// Update-Database -Project MessengerApi.Db.Sql -StartupProject MessengerApi.Db.Sql.Migrator -Verbose -Context MessengerSqlDbContext
}
}
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Db\MessengerApi.Db.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,31 @@
using MessengerApi.Db.Entities;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db.Sql
{
public class MessengerSqlDbContext : MessengerDbContext
{
private readonly string connectionString;
public MessengerSqlDbContext(string connectionString)
{
this.connectionString = connectionString;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseSqlServer(this.connectionString);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// https://stackoverflow.com/questions/26464357/what-is-the-purpose-of-non-unique-indexes-in-a-database
// https://stackoverflow.com/questions/40767980/generate-a-composite-unique-constraint-index-in-ef-core
// https://www.geeksforgeeks.org/difference-between-clustered-and-non-clustered-index/
modelBuilder.Entity<Message>().HasIndex(e => new { e.ToId, e.IsDelivered }).IsUnique(false).IsClustered(false);
}
}
}

View File

@ -0,0 +1,125 @@
// <auto-generated />
using System;
using MessengerApi.Db.Sql;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MessengerApi.Db.Sql.Migrations
{
[DbContext(typeof(MessengerSqlDbContext))]
[Migration("20250704165018_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MessengerApi.Db.Entities.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("datetime2");
b.Property<Guid>("FromId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsAcknowledged")
.HasColumnType("bit");
b.Property<bool>("IsDelivered")
.HasColumnType("bit");
b.Property<string>("Payload")
.HasColumnType("nvarchar(max)");
b.Property<int>("PayloadLifespanInSeconds")
.HasColumnType("int");
b.Property<string>("PayloadType")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("ToId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ToId", "IsDelivered");
SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("ToId", "IsDelivered"), false);
b.ToTable("Messages");
});
modelBuilder.Entity("MessengerApi.Db.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApiKey")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("FromId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ToId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("FromId");
b.HasIndex("ToId");
b.ToTable("UserRoutes");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.HasOne("MessengerApi.Db.Entities.User", "From")
.WithMany()
.HasForeignKey("FromId");
b.HasOne("MessengerApi.Db.Entities.User", "To")
.WithMany()
.HasForeignKey("ToId");
b.Navigation("From");
b.Navigation("To");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,100 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MessengerApi.Db.Sql.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Messages",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
FromId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ToId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
IsDelivered = table.Column<bool>(type: "bit", nullable: false),
IsAcknowledged = table.Column<bool>(type: "bit", nullable: false),
PayloadType = table.Column<string>(type: "nvarchar(max)", nullable: true),
Payload = table.Column<string>(type: "nvarchar(max)", nullable: true),
PayloadLifespanInSeconds = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Messages", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ApiKey = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsEnabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "UserRoutes",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FromId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
ToId = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserRoutes", x => x.Id);
table.ForeignKey(
name: "FK_UserRoutes_Users_FromId",
column: x => x.FromId,
principalTable: "Users",
principalColumn: "Id");
table.ForeignKey(
name: "FK_UserRoutes_Users_ToId",
column: x => x.ToId,
principalTable: "Users",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Messages_ToId_IsDelivered",
table: "Messages",
columns: new[] { "ToId", "IsDelivered" })
.Annotation("SqlServer:Clustered", false);
migrationBuilder.CreateIndex(
name: "IX_UserRoutes_FromId",
table: "UserRoutes",
column: "FromId");
migrationBuilder.CreateIndex(
name: "IX_UserRoutes_ToId",
table: "UserRoutes",
column: "ToId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Messages");
migrationBuilder.DropTable(
name: "UserRoutes");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@ -0,0 +1,122 @@
// <auto-generated />
using System;
using MessengerApi.Db.Sql;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MessengerApi.Db.Sql.Migrations
{
[DbContext(typeof(MessengerSqlDbContext))]
partial class MessengerSqlDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MessengerApi.Db.Entities.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("datetime2");
b.Property<Guid>("FromId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsAcknowledged")
.HasColumnType("bit");
b.Property<bool>("IsDelivered")
.HasColumnType("bit");
b.Property<string>("Payload")
.HasColumnType("nvarchar(max)");
b.Property<int>("PayloadLifespanInSeconds")
.HasColumnType("int");
b.Property<string>("PayloadType")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("ToId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ToId", "IsDelivered");
SqlServerIndexBuilderExtensions.IsClustered(b.HasIndex("ToId", "IsDelivered"), false);
b.ToTable("Messages");
});
modelBuilder.Entity("MessengerApi.Db.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ApiKey")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("FromId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ToId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("FromId");
b.HasIndex("ToId");
b.ToTable("UserRoutes");
});
modelBuilder.Entity("MessengerApi.Db.Entities.UserRoute", b =>
{
b.HasOne("MessengerApi.Db.Entities.User", "From")
.WithMany()
.HasForeignKey("FromId");
b.HasOne("MessengerApi.Db.Entities.User", "To")
.WithMany()
.HasForeignKey("ToId");
b.Navigation("From");
b.Navigation("To");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,8 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace MessengerApi.Db.Converters
{
public sealed class DateTimeAsUtcValueConverter()
: ValueConverter<DateTime, DateTime>(
v => v, v => new DateTime(v.Ticks, DateTimeKind.Utc));
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<BaseOutputPath>..\out\</BaseOutputPath>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Db.Contracts\MessengerApi.Db.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,26 @@
using MessengerApi.Db.Converters;
using MessengerApi.Db.Entities;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db
{
public abstract class MessengerDbContext : DbContext
{
public DbSet<User> Users { get; set; }
public DbSet<Message> Messages { get; set; }
public DbSet<UserRoute> UserRoutes { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<User>().HasKey(e => e.Id);
modelBuilder.Entity<Message>().HasKey(e => e.Id);
modelBuilder.Entity<Message>().Property(e => e.CreatedUtc).HasConversion<DateTimeAsUtcValueConverter>();
modelBuilder.Entity<Message>().Property(e => e.PayloadLifespanInSeconds).IsRequired();
modelBuilder.Entity<UserRoute>().HasKey(e => e.Id);
}
}
}

View File

@ -0,0 +1,23 @@
using MessengerApi.Db.Contracts.Repositories;
using MessengerApi.Db.Entities;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db.Repositories
{
public class MessageRepository : Repository<Message>, IMessageRepository
{
public MessageRepository(DbSet<Message> db) : base(db)
{
}
public IEnumerable<Message> GetPendingMessages(User user)
{
var timestamp = DateTime.UtcNow;
return this.db
.Where(x => x.ToId == user.Id && x.IsDelivered == false)
.Where(x => x.PayloadLifespanInSeconds == null || x.CreatedUtc.AddSeconds(x.PayloadLifespanInSeconds.Value) >= timestamp)
.OrderBy(x => x.CreatedUtc);
}
}
}

View File

@ -0,0 +1,26 @@
using MessengerApi.Db.Contracts.Entities;
using MessengerApi.Db.Contracts.Repositories;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db.Repositories
{
public abstract class Repository<T> : IRepository<T> where T : class, IEntity<T>
{
protected readonly DbSet<T> db;
public Repository(DbSet<T> db)
{
this.db = db;
}
public void Add(T entity)
{
this.db.Add(entity);
}
public T GetById(Guid id)
{
return this.db.Single(x => x.Id == id);
}
}
}

View File

@ -0,0 +1,18 @@
using MessengerApi.Db.Contracts.Repositories;
using MessengerApi.Db.Entities;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db.Repositories
{
public class UserRepository : Repository<User>, IUserRepository
{
public UserRepository(DbSet<User> db) : base(db)
{
}
public User SingleByApiKeyAndEnabled(Guid id, bool enabled)
{
return this.db.Single(x => x.ApiKey == id && x.IsEnabled == enabled);
}
}
}

View File

@ -0,0 +1,23 @@
using MessengerApi.Db.Contracts.Repositories;
using MessengerApi.Db.Entities;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Db.Repositories
{
public class UserRouteRepository : Repository<UserRoute>, IUserRouteRepository
{
public UserRouteRepository(DbSet<UserRoute> db) : base(db)
{
}
public IEnumerable<UserRoute> GetAllByUser(User sender)
{
return this.db.Include(x => x.From).Include(x => x.To).Where(x => x.From.Id == sender.Id || x.To.Id == sender.Id);
}
public IEnumerable<UserRoute> GetByFrom(User user)
{
return this.db.Include(x => x.From).Include(x => x.To).Where(x => x.From.Id == user.Id);
}
}
}

156
code/MessengerApi.sln Normal file
View File

@ -0,0 +1,156 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.11.35312.102
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Contracts", "MessengerApi.Contracts\MessengerApi.Contracts.csproj", "{833ED77F-A4E9-4FB3-BB84-4E55898B726A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.SubscriptionClient", "MessengerApi.SubscriptionClient\MessengerApi.SubscriptionClient.csproj", "{127D24B0-47F3-40E9-9136-899AFF206F19}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.QueryClient", "MessengerApi.QueryClient\MessengerApi.QueryClient.csproj", "{6441673B-2621-4E2C-A9A0-971E83C3F80A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{60B75400-A315-4B57-AFCF-5B4094785A62}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Examples.PhonebookClient", "MessengerApi.Examples.PhonebookClient\MessengerApi.Examples.PhonebookClient.csproj", "{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Examples.SnapshotSubscriptionClient", "MessengerApi.Examples.SnapshotSubscriptionClient\MessengerApi.Examples.SnapshotSubscriptionClient.csproj", "{A57429EB-3929-4E8B-B427-9B77D14CC486}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Examples.Constants", "MessengerApi.Examples.Constants\MessengerApi.Examples.Constants.csproj", "{7EC1857B-5BFD-46F6-809D-CE617CFD9A8C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Examples.QueryClient", "MessengerApi.Examples.QueryClient\MessengerApi.Examples.QueryClient.csproj", "{09DEF168-FD5C-47C3-81DF-077BE6219089}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi.Db", "MessengerApi.Db\MessengerApi.Db.csproj", "{64B33C4B-4B04-4F48-8620-4CA2AB641934}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessengerApi", "MessengerApi\MessengerApi.csproj", "{BA717183-65C4-4568-8ACD-DEDBD2B77322}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Db", "Db", "{F318E6F5-0343-491B-9264-CFFB4CCF1241}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{6FCD97D3-1EC8-4BB0-8BE1-245B9E51565A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Tests.LongTermSendingClient", "MessengerApi.Tests.LongTermSendingClient\MessengerApi.Tests.LongTermSendingClient.csproj", "{BAFCEB19-4FFC-44DF-8240-93172191080F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Tests.LongTermReceivingClient", "MessengerApi.Tests.LongTermReceivingClient\MessengerApi.Tests.LongTermReceivingClient.csproj", "{FE628370-BD9E-4745-8C5B-EDAA44BBA2BB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject
..\Directory.Packages.props = ..\Directory.Packages.props
..\docker-compose.yml = ..\docker-compose.yml
..\Dockerfile = ..\Dockerfile
..\NuGet.config = ..\NuGet.config
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
ProjectSection(SolutionItems) = preProject
..\.gitea\workflows\build.yml = ..\.gitea\workflows\build.yml
..\.gitea\workflows\docker-build-and-push.yml = ..\.gitea\workflows\docker-build-and-push.yml
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Configuration", "MessengerApi.Configuration\MessengerApi.Configuration.csproj", "{4588FB85-FD64-4B7F-B37A-6F2ADD403E80}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Db.Sql", "MessengerApi.Db.Sql\MessengerApi.Db.Sql.csproj", "{22755F3D-C55D-436C-9C9F-C564001B976B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Db.Npg", "MessengerApi.Db.Npg\MessengerApi.Db.Npg.csproj", "{8199D547-23AC-4B10-9BD1-2996A6C35B1D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Db.Contracts", "MessengerApi.Db.Contracts\MessengerApi.Db.Contracts.csproj", "{062ADC2E-EF77-4319-9269-D60D39E31C0E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Db.Sql.Migrator", "MessengerApi.Db.Sql.Migrator\MessengerApi.Db.Sql.Migrator.csproj", "{65C395EC-81E9-4919-9721-72CAA3E4780D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessengerApi.Db.Npg.Migrator", "MessengerApi.Db.Npg.Migrator\MessengerApi.Db.Npg.Migrator.csproj", "{DF751DD1-9869-4916-B946-A8513A7CE706}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{833ED77F-A4E9-4FB3-BB84-4E55898B726A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{833ED77F-A4E9-4FB3-BB84-4E55898B726A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{833ED77F-A4E9-4FB3-BB84-4E55898B726A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{833ED77F-A4E9-4FB3-BB84-4E55898B726A}.Release|Any CPU.Build.0 = Release|Any CPU
{127D24B0-47F3-40E9-9136-899AFF206F19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{127D24B0-47F3-40E9-9136-899AFF206F19}.Debug|Any CPU.Build.0 = Debug|Any CPU
{127D24B0-47F3-40E9-9136-899AFF206F19}.Release|Any CPU.ActiveCfg = Release|Any CPU
{127D24B0-47F3-40E9-9136-899AFF206F19}.Release|Any CPU.Build.0 = Release|Any CPU
{6441673B-2621-4E2C-A9A0-971E83C3F80A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6441673B-2621-4E2C-A9A0-971E83C3F80A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6441673B-2621-4E2C-A9A0-971E83C3F80A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6441673B-2621-4E2C-A9A0-971E83C3F80A}.Release|Any CPU.Build.0 = Release|Any CPU
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF}.Release|Any CPU.Build.0 = Release|Any CPU
{A57429EB-3929-4E8B-B427-9B77D14CC486}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A57429EB-3929-4E8B-B427-9B77D14CC486}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A57429EB-3929-4E8B-B427-9B77D14CC486}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A57429EB-3929-4E8B-B427-9B77D14CC486}.Release|Any CPU.Build.0 = Release|Any CPU
{7EC1857B-5BFD-46F6-809D-CE617CFD9A8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7EC1857B-5BFD-46F6-809D-CE617CFD9A8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7EC1857B-5BFD-46F6-809D-CE617CFD9A8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7EC1857B-5BFD-46F6-809D-CE617CFD9A8C}.Release|Any CPU.Build.0 = Release|Any CPU
{09DEF168-FD5C-47C3-81DF-077BE6219089}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{09DEF168-FD5C-47C3-81DF-077BE6219089}.Debug|Any CPU.Build.0 = Debug|Any CPU
{09DEF168-FD5C-47C3-81DF-077BE6219089}.Release|Any CPU.ActiveCfg = Release|Any CPU
{09DEF168-FD5C-47C3-81DF-077BE6219089}.Release|Any CPU.Build.0 = Release|Any CPU
{64B33C4B-4B04-4F48-8620-4CA2AB641934}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{64B33C4B-4B04-4F48-8620-4CA2AB641934}.Debug|Any CPU.Build.0 = Debug|Any CPU
{64B33C4B-4B04-4F48-8620-4CA2AB641934}.Release|Any CPU.ActiveCfg = Release|Any CPU
{64B33C4B-4B04-4F48-8620-4CA2AB641934}.Release|Any CPU.Build.0 = Release|Any CPU
{BA717183-65C4-4568-8ACD-DEDBD2B77322}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BA717183-65C4-4568-8ACD-DEDBD2B77322}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BA717183-65C4-4568-8ACD-DEDBD2B77322}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BA717183-65C4-4568-8ACD-DEDBD2B77322}.Release|Any CPU.Build.0 = Release|Any CPU
{BAFCEB19-4FFC-44DF-8240-93172191080F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BAFCEB19-4FFC-44DF-8240-93172191080F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BAFCEB19-4FFC-44DF-8240-93172191080F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BAFCEB19-4FFC-44DF-8240-93172191080F}.Release|Any CPU.Build.0 = Release|Any CPU
{FE628370-BD9E-4745-8C5B-EDAA44BBA2BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FE628370-BD9E-4745-8C5B-EDAA44BBA2BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FE628370-BD9E-4745-8C5B-EDAA44BBA2BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FE628370-BD9E-4745-8C5B-EDAA44BBA2BB}.Release|Any CPU.Build.0 = Release|Any CPU
{4588FB85-FD64-4B7F-B37A-6F2ADD403E80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4588FB85-FD64-4B7F-B37A-6F2ADD403E80}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4588FB85-FD64-4B7F-B37A-6F2ADD403E80}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4588FB85-FD64-4B7F-B37A-6F2ADD403E80}.Release|Any CPU.Build.0 = Release|Any CPU
{22755F3D-C55D-436C-9C9F-C564001B976B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{22755F3D-C55D-436C-9C9F-C564001B976B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{22755F3D-C55D-436C-9C9F-C564001B976B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{22755F3D-C55D-436C-9C9F-C564001B976B}.Release|Any CPU.Build.0 = Release|Any CPU
{8199D547-23AC-4B10-9BD1-2996A6C35B1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8199D547-23AC-4B10-9BD1-2996A6C35B1D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8199D547-23AC-4B10-9BD1-2996A6C35B1D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8199D547-23AC-4B10-9BD1-2996A6C35B1D}.Release|Any CPU.Build.0 = Release|Any CPU
{062ADC2E-EF77-4319-9269-D60D39E31C0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{062ADC2E-EF77-4319-9269-D60D39E31C0E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{062ADC2E-EF77-4319-9269-D60D39E31C0E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{062ADC2E-EF77-4319-9269-D60D39E31C0E}.Release|Any CPU.Build.0 = Release|Any CPU
{65C395EC-81E9-4919-9721-72CAA3E4780D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{65C395EC-81E9-4919-9721-72CAA3E4780D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{65C395EC-81E9-4919-9721-72CAA3E4780D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{65C395EC-81E9-4919-9721-72CAA3E4780D}.Release|Any CPU.Build.0 = Release|Any CPU
{DF751DD1-9869-4916-B946-A8513A7CE706}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DF751DD1-9869-4916-B946-A8513A7CE706}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DF751DD1-9869-4916-B946-A8513A7CE706}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DF751DD1-9869-4916-B946-A8513A7CE706}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{D2C7396F-B0CA-4BA5-A07A-C70DB283B3CF} = {60B75400-A315-4B57-AFCF-5B4094785A62}
{A57429EB-3929-4E8B-B427-9B77D14CC486} = {60B75400-A315-4B57-AFCF-5B4094785A62}
{7EC1857B-5BFD-46F6-809D-CE617CFD9A8C} = {60B75400-A315-4B57-AFCF-5B4094785A62}
{09DEF168-FD5C-47C3-81DF-077BE6219089} = {60B75400-A315-4B57-AFCF-5B4094785A62}
{64B33C4B-4B04-4F48-8620-4CA2AB641934} = {F318E6F5-0343-491B-9264-CFFB4CCF1241}
{BAFCEB19-4FFC-44DF-8240-93172191080F} = {6FCD97D3-1EC8-4BB0-8BE1-245B9E51565A}
{FE628370-BD9E-4745-8C5B-EDAA44BBA2BB} = {6FCD97D3-1EC8-4BB0-8BE1-245B9E51565A}
{02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}
{22755F3D-C55D-436C-9C9F-C564001B976B} = {F318E6F5-0343-491B-9264-CFFB4CCF1241}
{8199D547-23AC-4B10-9BD1-2996A6C35B1D} = {F318E6F5-0343-491B-9264-CFFB4CCF1241}
{062ADC2E-EF77-4319-9269-D60D39E31C0E} = {F318E6F5-0343-491B-9264-CFFB4CCF1241}
{65C395EC-81E9-4919-9721-72CAA3E4780D} = {F318E6F5-0343-491B-9264-CFFB4CCF1241}
{DF751DD1-9869-4916-B946-A8513A7CE706} = {F318E6F5-0343-491B-9264-CFFB4CCF1241}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {61948E36-4C2B-4BC9-80B6-9E155CE9F7DE}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.10",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@ -0,0 +1,7 @@
namespace MessengerApi
{
public class Constants
{
public const string USERFILE_FILENAME = "/app/users.conf";
}
}

View File

@ -0,0 +1,9 @@
using MessengerApi.Db;
namespace MessengerApi.Contracts.Factories
{
public interface IDbContextFactory
{
MessengerDbContext CreateDbContext();
}
}

View File

@ -0,0 +1,15 @@
using MessengerApi.Db.Contracts.Repositories;
namespace MessengerApi.Contracts.Models.Scoped
{
public interface IUnitOfWork
{
IUserRepository Users { get; }
IUserRouteRepository UserRoutes { get; }
IMessageRepository Messages { get; }
Task SaveChanges(CancellationToken ct = default);
}
}

View File

@ -0,0 +1,8 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER app
WORKDIR /app
EXPOSE 8080
COPY ./publish .
ENTRYPOINT ["dotnet", "MessengerApi.dll"]

View File

@ -0,0 +1,34 @@
using MessengerApi.Configuration.Model;
using MessengerApi.Configuration.Model.Persistence;
using MessengerApi.Contracts.Factories;
using MessengerApi.Db;
using MessengerApi.Db.Npg;
using MessengerApi.Db.Sql;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Factories
{
public class DbContextFactory : IDbContextFactory, IDbContextFactory<MessengerDbContext>
{
private readonly MessengerConfiguration configuration;
public DbContextFactory(MessengerConfiguration configuration)
{
this.configuration = configuration;
}
public MessengerDbContext CreateDbContext()
{
if (this.configuration.PersistenceConfiguration.PersistenceType == Configuration.Enums.PersistenceTypes.Sql)
{
return new MessengerSqlDbContext((configuration.PersistenceConfiguration as SqlPersistenceConfiguration).ConnectionString);
}
else if (this.configuration.PersistenceConfiguration.PersistenceType == Configuration.Enums.PersistenceTypes.PostgreSql)
{
return new MessengerNpgDbContext((configuration.PersistenceConfiguration as NpgPersistenceConfiguration).ConnectionString);
}
throw new NotImplementedException();
}
}
}

View File

@ -0,0 +1,32 @@
using MessengerApi.Configuration.Model;
namespace MessengerApi.Factories
{
public class LoggerFactory : IServiceProvider
{
private readonly MessengerConfiguration _configuration;
public LoggerFactory(MessengerConfiguration configuration)
{
_configuration = configuration;
}
public ILogger CreateLogger()
{
var logger = new ConsoleLogger()
{
IsDebugOutputEnabled = (this._configuration.Verbosity == Configuration.Enums.LoggingVerbosity.Debug || this._configuration.Verbosity == Configuration.Enums.LoggingVerbosity.Trace)
? true : false,
IsTraceOutputEnabled = this._configuration.Verbosity == Configuration.Enums.LoggingVerbosity.Trace
? true : false
};
return logger;
}
public object GetService(Type serviceType)
{
return this.CreateLogger();
}
}
}

View File

@ -0,0 +1,2 @@
global using portaloggy;
global using ILogger = portaloggy.ILogger;

View File

@ -0,0 +1,81 @@
using MessengerApi.Contracts.Models.Scoped;
using MessengerApi.Models;
using MessengerApi.Models.Scoped;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;
namespace MessengerApi.Handlers
{
/// <summary>
/// Validates our permananet API keys sent over as Bearer tokens.
/// </summary>
public class CustomBearerAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly IMemoryCache memoryCache;
public CustomBearerAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory loggerFactory,
UrlEncoder encoder,
IMemoryCache memoryCache)
: base(options, loggerFactory, encoder)
{
this.memoryCache = memoryCache;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
const string HEADER = "Authorization";
const string PREFIX = "Bearer ";
Context.RequestServices.GetRequiredService<Timing>(); // creates the object in scope.
if (!Request.Headers.TryGetValue(HEADER, out var authHeader) ||
!authHeader.ToString().StartsWith(PREFIX))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var token = authHeader.ToString().Substring(PREFIX.Length).Trim();
if(this.memoryCache.TryGetValue(token, out CachedIdentity oldCache))
{
var identity = Context.RequestServices.GetRequiredService<Identity>();
identity.User = oldCache.User;
identity.UserRoutes = oldCache.UserRoutes;
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(oldCache.ClaimsPrincipal, Scheme.Name)));
}
else
{
var unitOfWork = Context.RequestServices.GetRequiredService<IUnitOfWork>();
var user = unitOfWork.Users.SingleByApiKeyAndEnabled(Guid.Parse(token), true);
var routes = unitOfWork.UserRoutes.GetAllByUser(user).ToArray();
var principal = new ClaimsPrincipal(
new ClaimsIdentity(
new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Name),
new Claim(ClaimTypes.Name, user.Name)
}, Scheme.Name));
var cache = new CachedIdentity
{
ClaimsPrincipal = principal,
User = user,
UserRoutes = routes
};
this.memoryCache.Set(token, cache, TimeSpan.FromMinutes(5));
var identity = Context.RequestServices.GetRequiredService<Identity>();
identity.User = cache.User;
identity.UserRoutes = cache.UserRoutes;
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(cache.ClaimsPrincipal, Scheme.Name)));
}
}
}
}

View File

@ -0,0 +1,40 @@
using MessengerApi.Contracts.Models.Scoped;
using MessengerApi.Models.Scoped;
namespace MessengerApi.Handlers.Endpoint
{
public class AckEndpointHandler
{
private readonly ILogger logger;
private readonly IUnitOfWork unitOfWork;
private readonly Identity identity;
public AckEndpointHandler(
ILogger logger,
IUnitOfWork unitOfWork,
Identity identity)
{
this.logger = logger;
this.unitOfWork = unitOfWork;
this.identity = identity;
}
public async Task AckMessage(Guid messageId)
{
var message = unitOfWork.Messages.GetById(messageId);
// Authorize.
if (message.ToId != this.identity.User.Id)
{
throw new InvalidOperationException("It's not your message to ack.");
}
else if(!message.IsDelivered)
{
throw new InvalidOperationException("Can't ack undelivered message.");
}
// Act.
message.IsAcknowledged = true;
}
}
}

View File

@ -0,0 +1,35 @@
using MessengerApi.Contracts.Models.Scoped;
using MessengerApi.Models.Scoped;
namespace MessengerApi.Handlers.Endpoint
{
public class PeekEndpointHandler
{
private readonly ILogger logger;
private readonly Timing timing;
private readonly Identity identity;
private readonly IUnitOfWork unitOfWork;
public PeekEndpointHandler(
ILogger logger,
Timing timing,
Identity identity,
IUnitOfWork unitOfWork)
{
this.logger = logger;
this.timing = timing;
this.identity = identity;
this.unitOfWork = unitOfWork;
}
public Task<int> Peek()
{
var pendingMessages = this.unitOfWork.Messages.GetPendingMessages(this.identity.User);
this.logger.Debug($"[{this.timing.Timestamp:s}] User {this.identity.User.Name} is receiving {pendingMessages.Count()}.");
return Task.FromResult(pendingMessages.Count());
}
}
}

View File

@ -0,0 +1,43 @@
using MessengerApi.Contracts.Models.Scoped;
using MessengerApi.Db.Entities;
using MessengerApi.Models.Scoped;
namespace MessengerApi.Handlers.Endpoint
{
public class ReceiveEndpointHandler
{
private readonly ILogger logger;
private readonly Timing timing;
private readonly Identity identity;
private readonly IUnitOfWork unitOfWork;
public ReceiveEndpointHandler(
ILogger logger,
Timing timing,
Identity identity,
IUnitOfWork unitOfWork)
{
this.logger = logger;
this.timing = timing;
this.identity = identity;
this.unitOfWork = unitOfWork;
}
public Task<Message[]> ReceiveMessages()
{
var pendingMessages = this.unitOfWork.Messages.GetPendingMessages(this.identity.User);
this.logger.Debug($"[{this.timing.Timestamp:s}] User {this.identity.User.Name} is receiving {pendingMessages.Count()}.");
if (!pendingMessages.Any())
{
return Task.FromResult(new Message[0]);
}
var messages = pendingMessages.ToList();
messages.ForEach(x => x.IsDelivered = true);
return Task.FromResult(messages.ToArray());
}
}
}

View File

@ -0,0 +1,60 @@
using MessengerApi.Configuration.Model;
using MessengerApi.Contracts.Models.Scoped;
using MessengerApi.Db.Entities;
using MessengerApi.Models.Scoped;
namespace MessengerApi.Handlers.Endpoint
{
public class SendEndpointHandler
{
private readonly MessengerConfiguration configuration;
private readonly ILogger logger;
private readonly Timing timing;
private readonly Identity identity;
private readonly IUnitOfWork unitOfWork;
public SendEndpointHandler(
MessengerConfiguration configuration,
ILogger logger,
IUnitOfWork unitOfWork,
Timing timing,
Identity identity)
{
this.configuration = configuration;
this.logger = logger;
this.unitOfWork = unitOfWork;
this.timing = timing;
this.identity = identity;
}
public Task<Message> SendMessage(
Guid? toUserId,
string payload,
string payloadType,
int? payloadLifespanInSeconds)
{
// Authorize.
var targetRecipientId = toUserId.HasValue
? this.identity.UserRoutes.Single(x => x.From.Id == this.identity.User.Id && x.To.Id == toUserId.Value).To.Id
: this.identity.UserRoutes.Single().To.Id;
this.logger.Debug($"[{this.timing.Timestamp:s}] User {this.identity.User.Name} is authorized to send message to {targetRecipientId}.");
// Act.
var message = new Message
{
Id = Guid.NewGuid(),
CreatedUtc = this.timing.Timestamp,
FromId = this.identity.User.Id,
ToId = targetRecipientId,
Payload = payload,
PayloadType = payloadType,
PayloadLifespanInSeconds = payloadLifespanInSeconds ?? (this.configuration.DefaultMessageLifetimeInMinutes * 60)
};
this.unitOfWork.Messages.Add(message);
return Task.FromResult(message);
}
}
}

View File

@ -0,0 +1,47 @@
using MessengerApi.Configuration.Model;
using MessengerApi.Contracts.Factories;
using Microsoft.EntityFrameworkCore;
namespace MessengerApi.Handlers
{
public class HousekeepingHandler
{
private readonly ILogger logger;
private readonly MessengerConfiguration configuration;
private readonly IDbContextFactory dbContextFactory;
public HousekeepingHandler(
ILogger logger,
IDbContextFactory dbContextFactory,
MessengerConfiguration configuration)
{
this.logger = logger;
this.dbContextFactory = dbContextFactory;
this.configuration = configuration;
}
public async Task RemoveOldMessages()
{
this.logger.Trace($"Executing {nameof(this.RemoveOldMessages)}.");
var timestamp = DateTime.UtcNow;
var cutoff = timestamp.AddMinutes(-this.configuration.HousekeepingMessageAgeInMinutes);
using var ctx = this.dbContextFactory.CreateDbContext();
await ctx.Messages.Where(x => x.CreatedUtc < cutoff).ExecuteDeleteAsync();
if (this.configuration.HousekeepingMessageState != Configuration.Enums.HousekeepingMessageStates.None)
{
this.logger.Trace($"Executing additional message state cleaning in {nameof(this.RemoveOldMessages)}.");
if (this.configuration.HousekeepingMessageState == Configuration.Enums.HousekeepingMessageStates.Delivered)
{
await ctx.Messages.Where(x => x.IsDelivered).ExecuteDeleteAsync();
}
else if (this.configuration.HousekeepingMessageState == Configuration.Enums.HousekeepingMessageStates.Acknowledged)
{
await ctx.Messages.Where(x => x.IsAcknowledged).ExecuteDeleteAsync();
}
}
}
}
}

View File

@ -0,0 +1,99 @@
using MessengerApi.Configuration.Model;
using MessengerApi.Contracts.Factories;
using MessengerApi.Db;
using MessengerApi.Models;
using System.Text;
namespace MessengerApi.Handlers
{
// TODO: This needs to be redone, because at every run, it wipes users and creates new ones. This makes
// all existing DB messages unassignable.
public class UserSetupHandler
{
private readonly MessengerConfiguration configuration;
private readonly ILogger logger;
private readonly IDbContextFactory dbContextFactory;
public UserSetupHandler(
MessengerConfiguration configuration,
ILogger logger,
IDbContextFactory dbContextFactory)
{
this.configuration = configuration;
this.logger = logger;
this.dbContextFactory = dbContextFactory;
}
public async Task UpdateFromFile(FileInfo file)
{
if(file.Exists)
{
var lines = await File.ReadAllLinesAsync(file.FullName, Encoding.UTF8);
var items = await this.ReadLines(lines);
await this.SynchronizeUsers(items);
}
}
private async Task<UserSetupItem[]> ReadLines(string[] lines)
{
var items = new List<UserSetupItem>();
foreach (var line in lines)
{
var values = line.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var item = new UserSetupItem
{
UserName = values[0],
ApiKey = values[1],
};
if(values.Length > 2)
{
item.CanSendToUserNames = values[2].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
items.Add(item);
}
if (items.GroupBy(x => x.UserName).Any(x => x.Count() > 1))
{
throw new InvalidOperationException("Usernames are not unique. One username per line.");
}
else if(items.GroupBy(x=>x.ApiKey).Any(x=>x.Count() > 1))
{
throw new InvalidOperationException("API keys are not unique. One API key per line.");
}
return items.ToArray();
}
private Task SynchronizeUsers(IEnumerable<UserSetupItem> users)
{
using var db = this.dbContextFactory.CreateDbContext();
db.RemoveRange(db.Users);
db.RemoveRange(db.UserRoutes);
var dbUsers = users.Select(x => new Db.Entities.User
{
Id = new Guid(),
Name = x.UserName,
ApiKey = Guid.Parse(x.ApiKey),
IsEnabled = true
});
var dbRoutes = users.SelectMany(x => x.CanSendToUserNames.Select(cs => new Db.Entities.UserRoute
{
Id = new Guid(),
From = dbUsers.Single(dbu => dbu.Name == x.UserName),
To = dbUsers.Single(dbu => dbu.Name == x.UserName)
}));
db.AddRange(dbUsers);
db.AddRange(dbRoutes);
db.SaveChanges();
return Task.CompletedTask;
}
}
}

View File

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>85c81e87-1274-45ce-8b91-6d6619ffdfa2</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<BaseOutputPath>..\out\</BaseOutputPath>
<StartupObject>MessengerApi.Api.Program</StartupObject>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="portaloggy" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessengerApi.Configuration\MessengerApi.Configuration.csproj" />
<ProjectReference Include="..\MessengerApi.Contracts\MessengerApi.Contracts.csproj" />
<ProjectReference Include="..\MessengerApi.Db.Npg\MessengerApi.Db.Npg.csproj" />
<ProjectReference Include="..\MessengerApi.Db.Sql\MessengerApi.Db.Sql.csproj" />
<ProjectReference Include="..\MessengerApi.Db\MessengerApi.Db.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,13 @@
using System.Security.Claims;
namespace MessengerApi.Models
{
public class CachedIdentity
{
public Db.Entities.User User { get; set; }
public Db.Entities.UserRoute[] UserRoutes { get; set; }
public ClaimsPrincipal ClaimsPrincipal { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace MessengerApi.Models.Http
{
public class AckRequest
{
public Guid MessageId { get; set; }
}
}

View File

@ -0,0 +1,13 @@
namespace MessengerApi.Models.Http
{
public class SendRequest
{
public Guid? ToUserId { get; set; }
public string Payload { get; set; }
public string PayloadType { get; set; }
public int? PayloadLifetimeInSeconds { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace MessengerApi.Models.Http
{
public class VerifyRequest
{
public Guid MessageId { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace MessengerApi.Models.Scoped
{
public class Identity
{
public Db.Entities.User User { get; set; }
public Db.Entities.UserRoute[] UserRoutes { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace MessengerApi.Models.Scoped
{
public class Timing
{
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
}

View File

@ -0,0 +1,33 @@
using MessengerApi.Contracts.Factories;
using MessengerApi.Contracts.Models.Scoped;
using MessengerApi.Db;
using MessengerApi.Db.Contracts.Repositories;
using MessengerApi.Db.Repositories;
namespace MessengerApi.Models.Scoped
{
public class UnitOfWork : IUnitOfWork
{
private MessengerDbContext context;
public IUserRepository Users { get; }
public IUserRouteRepository UserRoutes { get; }
public IMessageRepository Messages { get; }
public UnitOfWork(
IDbContextFactory dbContextFactory)
{
this.context = dbContextFactory.CreateDbContext();
this.Users = new UserRepository(this.context.Users);
this.UserRoutes = new UserRouteRepository(this.context.UserRoutes);
this.Messages = new MessageRepository(this.context.Messages);
}
public Task SaveChanges(CancellationToken ct = default)
{
return this.context.SaveChangesAsync(ct);
}
}
}

View File

@ -0,0 +1,11 @@
namespace MessengerApi.Models
{
public class UserSetupItem
{
public string UserName { get; set; }
public string ApiKey { get; set; }
public string[] CanSendToUserNames { get; set; }
}
}

View File

@ -0,0 +1,313 @@
using MessengerApi.Configuration.Model;
using MessengerApi.Configuration.Model.Persistence;
using MessengerApi.Configuration.Sources.Environment;
using MessengerApi.Contracts.Factories;
using MessengerApi.Contracts.Models.Scoped;
using MessengerApi.Db;
using MessengerApi.Factories;
using MessengerApi.Handlers;
using MessengerApi.Handlers.Endpoint;
using MessengerApi.Models.Http;
using MessengerApi.Models.Scoped;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Threading.RateLimiting;
namespace MessengerApi.Api
{
public class Program
{
public static void Main(string[] args)
{
MessengerConfiguration configuration = null;
try
{
configuration = new MessengerConfiguration(new EnvironmentConfigurationSource());
}
catch (Exception ex)
{
Console.WriteLine("Can't load settings.", ex);
throw;
}
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables();
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<MessengerConfiguration>(configuration);
builder.Services.AddSingleton<ILogger>(new Factories.LoggerFactory(configuration).CreateLogger());
builder.Services.AddSingleton<SendEndpointHandler>();
builder.Services.AddSingleton<HousekeepingHandler>();
builder.Services.AddSingleton<UserSetupHandler>();
builder.Services.AddSingleton<IDbContextFactory, DbContextFactory>();
builder.Services.AddScoped<Timing>();
builder.Services.AddScoped<Identity>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<SendEndpointHandler>();
builder.Services.AddScoped<ReceiveEndpointHandler>();
builder.Services.AddScoped<AckEndpointHandler>();
builder.Services.AddScoped<PeekEndpointHandler>();
// Authentication.
builder.Services
.AddAuthentication("Bearer")
.AddScheme<AuthenticationSchemeOptions, CustomBearerAuthenticationHandler>("Bearer", null);
// CORS.
builder.Services
.AddCors(opt => opt.AddPolicy("originpolicy", builder =>
{
builder
.WithOrigins(configuration.Origins.ToArray())
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
}));
// Ratelimiting
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var key = httpContext.Request.Headers["Authorization"].FirstOrDefault()
?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = configuration.RateLimitPerMinute,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});
options.RejectionStatusCode = 429;
});
// Proxy registration to forward real client IPs.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (var proxy in configuration.Proxies)
{
options.KnownProxies.Add(IPAddress.Parse(proxy));
}
});
var app = builder.Build();
app.UseDeveloperExceptionPage();
// DB Migrations
using (var ctx = app.Services.GetRequiredService<IDbContextFactory>().CreateDbContext())
{
var migrationLogger = app.Services.GetRequiredService<ILogger>();
try
{
if (ctx.Database.GetPendingMigrations().Any())
{
migrationLogger.Info("Applying migrations.");
ctx.Database.Migrate();
}
else
{
migrationLogger.Info("No migrations pending.");
}
}
catch (Exception ex)
{
migrationLogger.Error("Can't run migrations successfully.", ex);
throw;
}
}
// Housekeeping.
if (configuration.HousekeepingEnabled)
{
_ = Task.Run(async () =>
{
while (true)
{
await app.Services.GetService<HousekeepingHandler>().RemoveOldMessages();
await Task.Delay(TimeSpan.FromMinutes(1));
}
});
}
// User synchronization
var userSetupHandler = app.Services.GetRequiredService<UserSetupHandler>();
userSetupHandler.UpdateFromFile(new FileInfo(Constants.USERFILE_FILENAME)).GetAwaiter().GetResult();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseCors("originpolicy");
app.UseForwardedHeaders();
// Ray id logging.
app.Use(async (context, next) =>
{
var stamp = DateTime.UtcNow;
var logger = context.RequestServices.GetRequiredService<ILogger>();
var ipa = context?.Connection?.RemoteIpAddress?.ToString() ?? "unknown";
var uid = context?.User?.Identity?.Name ?? "unknown";
var una = context?.User?.Claims?.SingleOrDefault(x => x.Type == "UserName")?.Value ?? "unknown";
var rid = context?.TraceIdentifier ?? "unknown";
var endpoint = context?.GetEndpoint()?.DisplayName ?? "unknown";
logger.Info($"{endpoint} call {rid}; ip {ipa}; u {una}/{uid}");
await next();
});
app.UseRateLimiter();
// Endpoint registration.
app.MapPost("/send", async (
ILogger logger,
IUnitOfWork unitOfWork,
SendEndpointHandler handler,
[FromBody] SendRequest request) =>
{
try
{
var response = await handler.SendMessage(request.ToUserId, request.Payload, request.PayloadType, request.PayloadLifetimeInSeconds);
await unitOfWork.SaveChanges();
return Results.Json(response.Id);
}
catch (Exception ex)
{
logger.Error("Can't send.", ex);
return Results.InternalServerError();
}
});
app.MapGet("/receive", async (
ILogger logger,
IUnitOfWork unitOfWork,
ReceiveEndpointHandler handler) =>
{
try
{
var messages = await handler.ReceiveMessages();
if (messages?.Any() != true)
{
return Results.NoContent();
}
else
{
await unitOfWork.SaveChanges();
return Results.Json(new
{
Messages = messages.Select(x => new
{
Id = x.Id,
TimestampUtc = x.CreatedUtc,
Payload = x.Payload,
PayloadType = x.PayloadType,
Sender = x.FromId
})
});
}
}
catch (Exception ex)
{
logger.Error("Can't send.", ex);
return Results.InternalServerError();
}
});
app.MapPost("/ack", async (
ILogger logger,
IUnitOfWork unitOfWork,
AckEndpointHandler handler,
AckRequest request) =>
{
try
{
await handler.AckMessage(request.MessageId);
await unitOfWork.SaveChanges();
return Results.Ok();
}
catch (Exception ex)
{
logger.Error("Can't send.", ex);
return Results.InternalServerError();
}
});
app.MapGet("/yellowpages", (
ILogger logger,
IUnitOfWork unitOfWork,
Identity identity) =>
{
try
{
var routes = unitOfWork.UserRoutes.GetByFrom(identity.User).ToList();
return Results.Json(new
{
Users = routes.Select(x => new
{
Id = x.To.Id,
Name = x.To.Name
})
});
}
catch (Exception ex)
{
logger.Error("Can't yellowpages.", ex);
return Results.InternalServerError();
}
});
app.MapGet("/peek", async (
ILogger logger,
PeekEndpointHandler handler) =>
{
try
{
var pending = await handler.Peek();
return Results.Json(pending);
}
catch (Exception ex)
{
logger.Error("Can't peek.", ex);
return Results.InternalServerError();
}
});
app.MapGet("/verify", (
ILogger logger,
IUnitOfWork unitOfWork,
Guid messageId) =>
{
try
{
var message = unitOfWork.Messages.GetById(messageId);
return Results.Json(new
{
message.IsDelivered,
message.IsAcknowledged
});
}
catch (Exception ex)
{
logger.Error("Can't verify.", ex);
return Results.InternalServerError();
}
});
app.Run();
}
}
}

View File

@ -0,0 +1,28 @@
{
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"PERSISTENCE_TYPE": "Sql",
"CORS_ORIGINS": "",
"PROXIES": "",
"QUERY_RATE_PER_MINUTE": "100",
"DEFAULT_MESSAGE_LIFETIME_IN_MINUTES": "60",
"HOUSEKEEPING_ENABLED": "False",
"LOGGING_VERBOSITY": "Trace"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5259"
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:55327",
"sslPort": 44348
}
}
}