diff --git a/src/server/ApiKeyMiddleware.cs b/src/server/ApiKeyMiddleware.cs new file mode 100644 index 0000000..81ef5a2 --- /dev/null +++ b/src/server/ApiKeyMiddleware.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Primitives; + +namespace server; + +public class ApiKeyMiddleware +{ + private readonly RequestDelegate _next; + private readonly IConfiguration _configuration; + + public ApiKeyMiddleware(RequestDelegate next, IConfiguration configuration) + { + _next = next; + _configuration = configuration; + } + + public async Task InvokeAsync(HttpContext context) + { + if (!context.Request.Headers.TryGetValue("X-API-KEY", out StringValues extractedApiKey)) + { + context.Response.StatusCode = 401; + await context.Response.WriteAsync("API Key is missing."); + return; + } + + var validApiKeys = _configuration.GetSection("Embeddingsearch").GetSection("ApiKeys").Get>(); + #pragma warning disable CS8604 + if (validApiKeys == null || !validApiKeys.Contains(extractedApiKey)) // CS8604 extractedApiKey is not null here, but the compiler still thinks that it might be. + { + context.Response.StatusCode = 403; + await context.Response.WriteAsync("Invalid API Key."); + return; + } + #pragma warning restore CS8604 + + await _next(context); + } +} \ No newline at end of file diff --git a/src/server/Controllers/EntityController.cs b/src/server/Controllers/EntityController.cs new file mode 100644 index 0000000..528c5bb --- /dev/null +++ b/src/server/Controllers/EntityController.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Mvc; +using embeddingsearch; +using System.Text.Json; +using server.Models; + +namespace server.Controllers; + +[ApiController] +[Route("[controller]")] +public class EntityController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IConfiguration _config; + private SearchomainManager _domainManager; + + public EntityController(ILogger logger, IConfiguration config, SearchomainManager domainManager) + { + _logger = logger; + _config = config; + _domainManager = domainManager; + } + + [HttpGet("Query")] + public ActionResult Query(string searchdomain, string query) + { + Searchdomain searchdomain_ = _domainManager.GetSearchdomain(searchdomain); + var results = searchdomain_.Search(query); + List queryResults = [.. results.Select(r => new EntityQueryResult + { + Name = r.Item2, + Value = r.Item1 + })]; + return Ok(new EntityQueryResults(){Results = queryResults}); + } + + [HttpGet("Index")] + public ActionResult Index(string searchdomain, string jsonEntity) + { + Searchdomain searchdomain_ = _domainManager.GetSearchdomain(searchdomain); + List? jsonEntities = JsonSerializer.Deserialize?>(jsonEntity); + if (jsonEntities is not null) + { + + List? entities = searchdomain_.EntitiesFromJSON(jsonEntity); + if (entities is not null) + { + return new EntityIndexResult() {Success = true}; + } + else + { + _logger.LogDebug("Unable to deserialize an entity"); + } + } + return new EntityIndexResult() {Success = false}; + } + + [HttpGet("List")] + public ActionResult List(string searchdomain) + { + EntityListResults entityListResults = new() {Results = []}; + Searchdomain searchdomain_ = _domainManager.GetSearchdomain(searchdomain); + foreach (Entity entity in searchdomain_.entityCache) + { + List attributeResults = []; + foreach (KeyValuePair attribute in entity.attributes) + { + attributeResults.Add(new AttributeResult() {Name = attribute.Key, Value = attribute.Value}); + } + List datapointResults = []; + foreach (Datapoint datapoint in entity.datapoints) + { + List embeddingResults = []; + foreach ((string, float[]) embedding in datapoint.embeddings) + { + embeddingResults.Add(new EmbeddingResult() {Model = embedding.Item1, Embeddings = embedding.Item2}); + } + datapointResults.Add(new DatapointResult() {Name = datapoint.name, ProbMethod = datapoint.probMethod.Method.Name, Embeddings = embeddingResults}); + } + EntityListResult entityListResult = new() + { + Name = entity.name, + Attributes = attributeResults, + Datapoints = datapointResults + }; + entityListResults.Results.Add(entityListResult); + } + return entityListResults; + } + + [HttpGet("Delete")] + public ActionResult Delete(string searchdomain, string entity) // TODO test this + { + Searchdomain searchdomain_ = _domainManager.GetSearchdomain(searchdomain); + Entity? entity_ = searchdomain_.GetEntity(entity); + if (entity_ is null) + { + return new EntityDeleteResults() {Success = false}; + } + searchdomain_.DatabaseRemoveEntity(entity); + return new EntityDeleteResults() {Success = true}; + } +} diff --git a/src/server/Controllers/SearchdomainController.cs b/src/server/Controllers/SearchdomainController.cs new file mode 100644 index 0000000..5f2d1d0 --- /dev/null +++ b/src/server/Controllers/SearchdomainController.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Mvc; +using embeddingsearch; +using server.Models; + +namespace server.Controllers; + +[ApiController] +[Route("[controller]")] +public class SearchdomainController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IConfiguration _config; + private SearchomainManager _domainManager; + + public SearchdomainController(ILogger logger, IConfiguration config, SearchomainManager domainManager) + { + _logger = logger; + _config = config; + _domainManager = domainManager; + } + + [HttpGet("List")] + public ActionResult List() + { + return Ok(_domainManager.ListSearchdomains()); + } + + [HttpGet("Create")] + public ActionResult Create(string searchdomain, string settings = "{}") + { + return Ok(new SearchdomainCreateResults(){Id = _domainManager.CreateSearchdomain(searchdomain, settings)}); + } + + [HttpGet("Delete")] + public ActionResult Delete(string searchdomain) + { + bool success; + int deletedEntries; + try + { + success = true; + deletedEntries = _domainManager.DeleteSearchdomain(searchdomain); + } catch (Exception) + { + success = false; + deletedEntries = 0; + } + return Ok(new SearchdomainDeleteResults(){Success = success, DeletedEntities = deletedEntries}); + } + + [HttpGet("Update")] + public ActionResult Update(string searchdomain, string newName, string settings = "{}") + { + Searchdomain searchdomain_ = _domainManager.GetSearchdomain(searchdomain); + Dictionary parameters = new() + { + {"name", newName}, + {"settings", settings}, + {"id", searchdomain_.id} + }; + searchdomain_.ExecuteSQLNonQuery("UPDATE searchdomain set name = @name, settings = @settings WHERE id = @id", parameters); + return Ok(new SearchdomainUpdateResults(){Success = true}); + } +} diff --git a/src/server/Models/EntityModels.cs b/src/server/Models/EntityModels.cs new file mode 100644 index 0000000..573bf8d --- /dev/null +++ b/src/server/Models/EntityModels.cs @@ -0,0 +1,55 @@ +namespace server.Models; + + +public class EntityQueryResults +{ + public required List Results { get; set; } +} + +public class EntityQueryResult +{ + public required string Name { get; set; } + public float Value { get; set; } +} + +public class EntityIndexResult +{ + public required bool Success { get; set; } +} + +public class EntityListResults +{ + public required List Results { get; set; } +} + +public class EntityListResult +{ + public required string Name { get; set; } + public required List Attributes { get; set; } + public required List Datapoints { get; set; } +} + +public class AttributeResult +{ + public required string Name { get; set; } + public required string Value { get; set; } +} + +public class DatapointResult +{ + public required string Name { get; set; } + public required string ProbMethod { get; set; } + public required List Embeddings { get; set; } +} + +public class EmbeddingResult +{ + public required string Model { get; set; } + public required float[] Embeddings { get; set; } +} + +public class EntityDeleteResults +{ + public required bool Success { get; set; } +} + diff --git a/src/server/Models/SearchdomainModels.cs b/src/server/Models/SearchdomainModels.cs new file mode 100644 index 0000000..fe88c4f --- /dev/null +++ b/src/server/Models/SearchdomainModels.cs @@ -0,0 +1,24 @@ +namespace server.Models +{ + public class SearchdomainListResults + { + public required List Searchdomains { get; set; } + } + + public class SearchdomainCreateResults + { + public required int Id { get; set; } + } + + public class SearchdomainUpdateResults + { + public required bool Success { get; set; } + } + + public class SearchdomainDeleteResults + { + public required bool Success { get; set; } + public required int DeletedEntities { get; set; } + } + +} diff --git a/src/server/Program.cs b/src/server/Program.cs new file mode 100644 index 0000000..d4681c7 --- /dev/null +++ b/src/server/Program.cs @@ -0,0 +1,31 @@ +using server; + +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.AddSingleton(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} +else +{ + app.UseMiddleware(); +} + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/src/server/SearchdomainManager.cs b/src/server/SearchdomainManager.cs new file mode 100644 index 0000000..c14666c --- /dev/null +++ b/src/server/SearchdomainManager.cs @@ -0,0 +1,116 @@ +using embeddingsearch; +using MySql.Data.MySqlClient; +using System.Data.Common; +using OllamaSharp; + +namespace server; + +public class SearchomainManager +{ + private Dictionary searchdomains = []; + private readonly ILogger _logger; + private readonly IConfiguration _config; + private readonly string ollamaURL; + private readonly string connectionString; + private OllamaApiClient client; + private MySqlConnection connection; + + public SearchomainManager(ILogger logger, IConfiguration config) + { + _logger = logger; + _config = config; + ollamaURL = _config.GetSection("Embeddingsearch")["OllamaURL"] ?? ""; + connectionString = _config.GetSection("Embeddingsearch").GetConnectionString("SQL") ?? ""; + client = new(new Uri(ollamaURL)); + connection = new MySqlConnection(connectionString); + connection.Open(); + } + + public Searchdomain GetSearchdomain(string searchdomain) + { + if (searchdomains.TryGetValue(searchdomain, out Searchdomain? value)) + { + return value; + } + try + { + return SetSearchdomain(searchdomain, new Searchdomain(searchdomain, connectionString, client)); + } catch (MySqlException) + { + _logger.LogError("Unable to find the searchdomain {searchdomain}", searchdomain); + throw new Exception($"Unable to find the searchdomain {searchdomain}"); + } + } + + public List ListSearchdomains() + { + DbDataReader reader = ExecuteSQLCommand("SELECT name FROM searchdomain", []); + List results = []; + while (reader.Read()) + { + results.Add(reader.GetString(0)); + } + reader.Close(); + return results; + } + + public int CreateSearchdomain(string searchdomain, string settings = "{}") + { + Dictionary parameters = new() + { + { "name", searchdomain }, + { "settings", settings} + }; + return ExecuteSQLCommandGetInsertedID("INSERT INTO searchdomain (name, settings) VALUES (@name, @settings)", parameters); + } + + public int DeleteSearchdomain(string searchdomain) + { + Searchdomain searchdomain_ = GetSearchdomain(searchdomain); + int counter = 0; + foreach (Entity entity in searchdomain_.entityCache) + { + searchdomain_.DatabaseRemoveEntity(entity.name); + counter += 1; + } + _logger.LogDebug($"Number of entities deleted as part of deleting the searchdomain \"{searchdomain}\": {counter}"); + searchdomain_.ExecuteSQLNonQuery("DELETE FROM entity WHERE id_searchdomain = @id", new() {{"id", searchdomain_.id}}); // Cleanup // TODO add rows affected + searchdomain_.ExecuteSQLNonQuery("DELETE FROM searchdomain WHERE name = @name", new() {{"name", searchdomain}}); + searchdomains.Remove(searchdomain); + _logger.LogDebug($"Searchdomain has been successfully removed"); + return counter; + } + + public DbDataReader ExecuteSQLCommand(string query, Dictionary parameters) + { + using MySqlCommand command = connection.CreateCommand(); + command.CommandText = query; + foreach (KeyValuePair parameter in parameters) + { + command.Parameters.AddWithValue($"@{parameter.Key}", parameter.Value); + } + return command.ExecuteReader(); + } + + public int ExecuteSQLCommandGetInsertedID(string query, Dictionary parameters) + { + using MySqlCommand command = connection.CreateCommand(); + + command.CommandText = query; + foreach (KeyValuePair parameter in parameters) + { + command.Parameters.AddWithValue($"@{parameter.Key}", parameter.Value); + } + command.ExecuteNonQuery(); + command.CommandText = "SELECT LAST_INSERT_ID();"; + return Convert.ToInt32(command.ExecuteScalar()); + } + + private Searchdomain SetSearchdomain(string name, Searchdomain searchdomain) + { + searchdomains[name] = searchdomain; + return searchdomain; + } + + +} diff --git a/src/server/appsettings.json b/src/server/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/src/server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/server/server.csproj b/src/server/server.csproj new file mode 100644 index 0000000..4b546bb --- /dev/null +++ b/src/server/server.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + +