From c163b63aa1ec47d42608eb69275bea4b5dda7d5d Mon Sep 17 00:00:00 2001 From: MUEHLHEIM Marc Date: Wed, 28 Feb 2024 12:16:06 +0100 Subject: [PATCH 1/6] Update .gitignore --- backend/.gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/.gitignore b/backend/.gitignore index 2340c0d..69967c5 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -339,4 +339,5 @@ ASALocalRun/ .localhistory/ # BeatPulse healthcheck temp database -healthchecksdb \ No newline at end of file +healthchecksdb +local.settings.json \ No newline at end of file From 6a22d0c99dc469406bcd4f8bca08001254e4a876 Mon Sep 17 00:00:00 2001 From: MUEHLHEIM Marc Date: Mon, 15 Apr 2024 20:25:55 +0200 Subject: [PATCH 2/6] Update logging --- backend/host.json | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/host.json b/backend/host.json index a85a33a..e1ca905 100644 --- a/backend/host.json +++ b/backend/host.json @@ -2,10 +2,18 @@ "version": "2.0", "logging": { "applicationInsights": { - "samplingExcludedTypes": "Request", + "samplingExcludedTypes": "Request;Exception", "samplingSettings": { - "isEnabled": true + "maxTelemetryItemsPerSecond": 20, + "isEnabled": true, + "excludedTypes": "Request;Exception" + }, + "logLevel": { + "default": "Warning", + "Host.Results": "Information", + "Function": "Information", + "Function.DefikarteBackend": "Information" } } } -} \ No newline at end of file +} From 7777e6f853e5a1e9640b177b26cc8939c03d9a0f Mon Sep 17 00:00:00 2001 From: MUEHLHEIM Marc Date: Mon, 15 Apr 2024 21:27:36 +0200 Subject: [PATCH 3/6] Make access a string-field allowing multiple values, also null. --- backend/DefibrillatorFunction.cs | 2 +- backend/Model/DefibrillatorRequest.cs | 2 +- .../DefibrillatorRequestValidator.cs | 18 ++++++++++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/backend/DefibrillatorFunction.cs b/backend/DefibrillatorFunction.cs index a47ec55..fb4f41f 100644 --- a/backend/DefibrillatorFunction.cs +++ b/backend/DefibrillatorFunction.cs @@ -159,7 +159,7 @@ private static Node CreateNode(DefibrillatorRequest request) "operator", request.Operator }, { - "access", request.Access ? "yes" : "no" + "access", request.Access }, { "indoor", request.Indoor ? "yes" : "no" diff --git a/backend/Model/DefibrillatorRequest.cs b/backend/Model/DefibrillatorRequest.cs index 483e6ec..5a766b1 100644 --- a/backend/Model/DefibrillatorRequest.cs +++ b/backend/Model/DefibrillatorRequest.cs @@ -14,7 +14,7 @@ public class DefibrillatorRequest public string Description { get; set; } - public bool Access { get; set; } + public string Access { get; set; } public string Operator { get; set; } diff --git a/backend/Validation/DefibrillatorRequestValidator.cs b/backend/Validation/DefibrillatorRequestValidator.cs index 515221b..b5fb533 100644 --- a/backend/Validation/DefibrillatorRequestValidator.cs +++ b/backend/Validation/DefibrillatorRequestValidator.cs @@ -1,5 +1,6 @@ using DefikarteBackend.Model; using FluentValidation; +using System.Collections.Generic; using System.Text.RegularExpressions; namespace DefikarteBackend.Validation @@ -14,13 +15,13 @@ public DefibrillatorRequestValidator() RuleFor(x => x.Location).NotEmpty().MaximumLength(200); RuleFor(x => x.Description).MaximumLength(200); RuleFor(x => x.OperatorPhone).Custom((value, context) => PhoneNumberValid(value, context)).When(x => !string.IsNullOrEmpty(x.OperatorPhone)); - RuleFor(x => x.Access).NotNull(); + RuleFor(x => x.Access).Custom((value, context) => AccessValid(value, context)); RuleFor(x => x.Indoor).NotNull(); RuleFor(x => x.EmergencyPhone).NotEmpty().Custom((value, context) => PhoneNumberValid(value, context)); // opening hours validation is missing } - private void PhoneNumberValid(string phoneNumberRaw, ValidationContext context) + private static void PhoneNumberValid(string phoneNumberRaw, ValidationContext context) { if (string.IsNullOrEmpty(phoneNumberRaw)) { @@ -53,5 +54,18 @@ private void PhoneNumberValid(string phoneNumberRaw, ValidationContext context) + { + if (string.IsNullOrEmpty(access)) + { + return; + } + + if (!(new List { "yes", "no", "private", "permissive" }).Contains(access)) + { + context.AddFailure(context.PropertyName, "Access not valid"); + } + } } } \ No newline at end of file From 525f8f802519d90ef20e7c7a129629759e7bc3cf Mon Sep 17 00:00:00 2001 From: MUEHLHEIM Marc Date: Tue, 16 Apr 2024 08:48:10 +0200 Subject: [PATCH 4/6] Update version to handle access as string, add openAPI doumentation, add geojson support on v2 --- backend/Cache/BlobStorageCacheRepository.cs | 11 - backend/Cache/BlobStorageCacheRepositoryV2.cs | 75 +++++++ backend/Cache/IBlobStorageCacheRepository.cs | 15 ++ backend/Cache/IGeoJsonCacheRepository.cs | 14 ++ .../CustomOpenApiConfigurationOptions.cs | 26 +++ backend/Configuration/ServiceConfiguration.cs | 3 + backend/DefibrillatorFunction.cs | 9 +- backend/DefibrillatorFunctionV2.cs | 205 ++++++++++++++++++ backend/DefikarteBackend.csproj | 9 +- backend/Model/DefibrillatorRequest.cs | 2 +- backend/Model/DefibrillatorRequestV2.cs | 33 +++ backend/Model/DefibrillatorResponse.cs | 30 +++ backend/Model/FeatureCollection.cs | 29 +++ backend/SimpleCacheFunction.cs | 38 +++- backend/Startup.cs | 5 + .../DefibrillatorRequestValidator.cs | 2 +- .../DefibrillatorRequestValidatorV2.cs | 71 ++++++ 17 files changed, 556 insertions(+), 21 deletions(-) create mode 100644 backend/Cache/BlobStorageCacheRepositoryV2.cs create mode 100644 backend/Cache/IBlobStorageCacheRepository.cs create mode 100644 backend/Cache/IGeoJsonCacheRepository.cs create mode 100644 backend/Configuration/CustomOpenApiConfigurationOptions.cs create mode 100644 backend/DefibrillatorFunctionV2.cs create mode 100644 backend/Model/DefibrillatorRequestV2.cs create mode 100644 backend/Model/DefibrillatorResponse.cs create mode 100644 backend/Model/FeatureCollection.cs create mode 100644 backend/Validation/DefibrillatorRequestValidatorV2.cs diff --git a/backend/Cache/BlobStorageCacheRepository.cs b/backend/Cache/BlobStorageCacheRepository.cs index fd05933..5876146 100644 --- a/backend/Cache/BlobStorageCacheRepository.cs +++ b/backend/Cache/BlobStorageCacheRepository.cs @@ -9,17 +9,6 @@ namespace DefikarteBackend.Cache { - public interface IBlobStorageCacheRepository - { - Task CreateAsync(string jsonData, string blobName); - - Task ReadAsync(string blobName); - - Task UpdateAsync(string jsonData, string blobName); - - Task DeleteAsync(string blobName); - } - public class BlobStorageCacheRepository : IBlobStorageCacheRepository, ICacheRepository { private readonly BlobContainerClient _containerClient; diff --git a/backend/Cache/BlobStorageCacheRepositoryV2.cs b/backend/Cache/BlobStorageCacheRepositoryV2.cs new file mode 100644 index 0000000..258aaa2 --- /dev/null +++ b/backend/Cache/BlobStorageCacheRepositoryV2.cs @@ -0,0 +1,75 @@ +using Azure.Storage.Blobs; +using DefikarteBackend.Model; +using Newtonsoft.Json; +using System; +using System.Text; +using System.Threading.Tasks; +using System.Linq; + +namespace DefikarteBackend.Cache +{ + public class BlobStorageCacheRepositoryV2 : IBlobStorageCacheRepository, IGeoJsonCacheRepository + { + private readonly BlobContainerClient _containerClient; + private readonly string _blobName; + + public BlobStorageCacheRepositoryV2(BlobContainerClient containerClient, string blobName) + { + _containerClient = containerClient; + _blobName = blobName; + } + + public async Task CreateAsync(string jsonData, string blobName) + { + BlobClient blobClient = _containerClient.GetBlobClient(blobName); + await blobClient.UploadAsync(BinaryData.FromString(jsonData)); + } + + public async Task ReadAsync(string blobName) + { + BlobClient blobClient = _containerClient.GetBlobClient(blobName); + var response = await blobClient.DownloadContentAsync(); + var content = response.Value.Content; + return Encoding.UTF8.GetString(content); + } + + public async Task UpdateAsync(string jsonData, string blobName) + { + BlobClient blobClient = _containerClient.GetBlobClient(blobName); + await blobClient.UploadAsync(BinaryData.FromString(jsonData), overwrite: true); + } + + public async Task DeleteAsync(string blobName) + { + BlobClient blobClient = _containerClient.GetBlobClient(blobName); + await blobClient.DeleteIfExistsAsync(); + } + + public async Task GetAsync() + { + var content = await ReadAsync(_blobName); + return JsonConvert.DeserializeObject(content); + } + + public async Task GetByIdAsync(string id) + { + return (await GetAsync()).Features.FirstOrDefault(x => x.Id == id); + } + + public async Task TryUpdateCacheAsync(FeatureCollection values) + { + var success = false; + try + { + await UpdateAsync(JsonConvert.SerializeObject(values), _blobName); + success = true; + } + catch (Exception ex) + { + Console.WriteLine(ex); + } + + return success; + } + } +} \ No newline at end of file diff --git a/backend/Cache/IBlobStorageCacheRepository.cs b/backend/Cache/IBlobStorageCacheRepository.cs new file mode 100644 index 0000000..085b191 --- /dev/null +++ b/backend/Cache/IBlobStorageCacheRepository.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; + +namespace DefikarteBackend.Cache +{ + public interface IBlobStorageCacheRepository + { + Task CreateAsync(string jsonData, string blobName); + + Task ReadAsync(string blobName); + + Task UpdateAsync(string jsonData, string blobName); + + Task DeleteAsync(string blobName); + } +} \ No newline at end of file diff --git a/backend/Cache/IGeoJsonCacheRepository.cs b/backend/Cache/IGeoJsonCacheRepository.cs new file mode 100644 index 0000000..6e09f03 --- /dev/null +++ b/backend/Cache/IGeoJsonCacheRepository.cs @@ -0,0 +1,14 @@ +using DefikarteBackend.Model; +using System.Threading.Tasks; + +namespace DefikarteBackend.Cache +{ + public interface IGeoJsonCacheRepository + { + Task GetAsync(); + + Task GetByIdAsync(string id); + + Task TryUpdateCacheAsync(FeatureCollection values); + } +} diff --git a/backend/Configuration/CustomOpenApiConfigurationOptions.cs b/backend/Configuration/CustomOpenApiConfigurationOptions.cs new file mode 100644 index 0000000..4245906 --- /dev/null +++ b/backend/Configuration/CustomOpenApiConfigurationOptions.cs @@ -0,0 +1,26 @@ +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Configurations; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums; +using Microsoft.OpenApi.Models; +using System; + +namespace DefikarteBackend.Configuration +{ + public class CustomOpenApiConfigurationOptions : DefaultOpenApiConfigurationOptions + { + public override OpenApiInfo Info { get; set; } = new OpenApiInfo() + { + Version = "1.0.0", + Title = "OpenAPI Document for Defikarte.ch-API", + Description = "HTTP APIs used for the Defikarte.ch", + TermsOfService = new Uri("https://defikarte.ch/impressum.html"), + Contact = new OpenApiContact() + { + Name = "Defikarte.ch-API", + Email = "info@defikarte.ch", + Url = new Uri("https://github.com/OpenBracketsCH/defikarte.ch-app/issues"), + }, + }; + + public override OpenApiVersionType OpenApiVersion { get; set; } = OpenApiVersionType.V3; + } +} diff --git a/backend/Configuration/ServiceConfiguration.cs b/backend/Configuration/ServiceConfiguration.cs index ac84d0e..9de9b74 100644 --- a/backend/Configuration/ServiceConfiguration.cs +++ b/backend/Configuration/ServiceConfiguration.cs @@ -16,6 +16,8 @@ public class ServiceConfiguration public string BlobStorageBlobName { get; set; } + public string BlobStorageBlobNameV2 { get; set; } + public string BlobStoragaConnectionString { get; set; } public static ServiceConfiguration Initialize(IConfigurationRoot configuration) @@ -29,6 +31,7 @@ public static ServiceConfiguration Initialize(IConfigurationRoot configuration) BlobStoragaConnectionString = configuration.GetConnectionStringOrSetting("AzureWebJobsStorage"), BlobStorageContainerName = configuration.GetConnectionStringOrSetting("BLOB_STORAGE_CONTAINER_NAME"), BlobStorageBlobName = configuration.GetConnectionStringOrSetting("BLOB_STORAGE_BLOB_NAME"), + BlobStorageBlobNameV2 = configuration.GetConnectionStringOrSetting("BLOB_STORAGE_BLOB_NAME_V2"), }; } } diff --git a/backend/DefibrillatorFunction.cs b/backend/DefibrillatorFunction.cs index fb4f41f..0c9a1f4 100644 --- a/backend/DefibrillatorFunction.cs +++ b/backend/DefibrillatorFunction.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Net; using System.Net.Http; using System.Threading.Tasks; using System.Web.Http; @@ -13,6 +14,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using OsmSharp; @@ -33,6 +35,8 @@ public DefibrillatorFunction(ServiceConfiguration config, ICacheRepository), Description = "The OK response")] public async Task GetAll( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "defibrillator")] HttpRequestMessage req, ILogger log) @@ -67,6 +71,9 @@ public async Task GetAll( [FunctionName("Defibrillators_POST")] + [OpenApiOperation(operationId: "CreateDefibrillator_V1")] + [OpenApiRequestBody("application/json", typeof(DefibrillatorRequest))] + [OpenApiResponseWithBody(statusCode: HttpStatusCode.Created, contentType: "application/json", bodyType: typeof(DefibrillatorResponse), Description = "The OK response")] public async Task Create( [HttpTrigger(AuthorizationLevel.Function, "Post", Route = "defibrillator")] HttpRequest req, ILogger log) @@ -159,7 +166,7 @@ private static Node CreateNode(DefibrillatorRequest request) "operator", request.Operator }, { - "access", request.Access + "access", request.Access ? "yes" : "no" }, { "indoor", request.Indoor ? "yes" : "no" diff --git a/backend/DefibrillatorFunctionV2.cs b/backend/DefibrillatorFunctionV2.cs new file mode 100644 index 0000000..d5c4dfc --- /dev/null +++ b/backend/DefibrillatorFunctionV2.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using System.Web.Http; +using DefikarteBackend.Cache; +using DefikarteBackend.Configuration; +using DefikarteBackend.Model; +using DefikarteBackend.OsmOverpassApi; +using DefikarteBackend.Validation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using OsmSharp; +using OsmSharp.IO.API; +using OsmSharp.Tags; + +namespace DefikarteBackend +{ + public class DefibrillatorFunctionV2 + { + private readonly ServiceConfiguration _config; + private readonly IGeoJsonCacheRepository _cacheRepository; + + public DefibrillatorFunctionV2(ServiceConfiguration config, IGeoJsonCacheRepository cacheRepository) + { + _config = config; + _cacheRepository = cacheRepository; + } + + [FunctionName("Defibrillators_GETALL_V2")] + [OpenApiOperation(operationId: "GetDefibrillators_V2")] + [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: "application/json", bodyType: typeof(FeatureCollection), Description = "The OK response")] + public async Task GetAll( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "v2/defibrillator")] HttpRequestMessage req, + ILogger log) + { + try + { + if (TryParseIdQuery(req.RequestUri.ParseQueryString(), out var id)) + { + var byIdResponse = await _cacheRepository.GetByIdAsync(id); + return new OkObjectResult(byIdResponse); + } + + var response = await _cacheRepository.GetAsync(); + if (response != null && response.Features.Count > 0) + { + log.LogInformation($"Get all AED from cache. Count: {response.Features.Count}"); + return new OkObjectResult(response); + } + + var overpassApiUrl = _config.OverpassApiUrl; + log.LogInformation($"Get all AED from {overpassApiUrl}. Cache is not available."); + + var overpassApiClient = new OverpassClient(overpassApiUrl); + var overpassResponse = await overpassApiClient.GetAllDefibrillatorsInSwitzerland(); + return new OkObjectResult(overpassResponse); + } + catch (Exception ex) + { + return new ExceptionResult(ex, false); + } + } + + + [FunctionName("Defibrillators_POST_V2")] + [OpenApiOperation(operationId: "CreateDefibrillator_V2")] + [OpenApiRequestBody("application/json", typeof(DefibrillatorRequestV2))] + [OpenApiResponseWithBody(statusCode: HttpStatusCode.Created, contentType: "application/json", bodyType: typeof(DefibrillatorResponse), Description = "The OK response")] + public async Task Create( + [HttpTrigger(AuthorizationLevel.Function, "Post", Route = "v2/defibrillator")] HttpRequest req, + ILogger log) + { + try + { + var username = _config.OsmUserName; + var password = _config.OsmUserPassword; + var osmApiUrl = _config.OsmApiUrl; + + if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password) || string.IsNullOrEmpty(osmApiUrl)) + { + log.LogWarning("No valid configuration available for eighter username, password or osmApiUrl"); + return new InternalServerErrorResult(); + } + + var defibrillatorObj = await req.GetJsonBodyAsync(); + + if (!defibrillatorObj.IsValid) + { + log.LogInformation($"Invalid request data."); + return defibrillatorObj.ToBadRequest(); + } + + var newNode = CreateNode(defibrillatorObj.Value); + var clientFactory = new ClientsFactory(log, new HttpClient(), osmApiUrl); + + var authClient = clientFactory.CreateBasicAuthClient(username, password); + var changeSetTags = new TagsCollection() { new Tag("created_by", username), new Tag("comment", "Create new AED.") }; + var changeSetId = await authClient.CreateChangeset(changeSetTags); + + newNode.ChangeSetId = changeSetId; + var nodeId = await authClient.CreateElement(changeSetId, newNode); + + await authClient.CloseChangeset(changeSetId); + + var createdNode = await authClient.GetNode(nodeId); + + log.LogInformation($"Added new node {nodeId}"); + return new OkObjectResult(createdNode) { StatusCode = 201 }; + } + catch (JsonSerializationException ex) + { + log.LogError(ex.ToString()); + return new BadRequestObjectResult(ex.Message); + } + catch (Exception ex) + { + log.LogError(ex.ToString()); + return new ExceptionResult(ex, false); + } + } + + private static bool TryParseIdQuery(NameValueCollection query, out string id) + { + id = string.Empty; + try + { + var idValues = query.GetValues("id"); + bool available = idValues != null && idValues.Length > 0; + id = idValues != null && idValues.Length > 0 ? idValues[0] : string.Empty; + return available; + } + catch (Exception) + { + return false; + } + } + + private static Node CreateNode(DefibrillatorRequestV2 request) + { + var tags = new Dictionary + { + { + "emergency", "defibrillator" + }, + { + "emergency:phone", request.EmergencyPhone + }, + { + "defibrillator:location", request.Location + }, + { + "opening_hours", request.OpeningHours + }, + { + "phone", request.OperatorPhone + }, + { + "operator", request.Operator + }, + { + "access", request.Access + }, + { + "indoor", request.Indoor ? "yes" : "no" + }, + { + "description", request.Description + }, + { + "level", request.Level + }, + { + "source", request.Source + }, + }; + + var keysToRemove = new List(); + // remove empty values + foreach (var keyval in tags) + { + if (string.IsNullOrEmpty(keyval.Value)) + { + keysToRemove.Add(keyval.Key); + } + } + + keysToRemove.ForEach(r => tags.Remove(r)); + + return new Node() + { + Latitude = request.Latitude, + Longitude = request.Longitude, + Tags = new TagsCollection(tags), + }; + } + } +} diff --git a/backend/DefikarteBackend.csproj b/backend/DefikarteBackend.csproj index 51e1a38..af238aa 100644 --- a/backend/DefikarteBackend.csproj +++ b/backend/DefikarteBackend.csproj @@ -8,12 +8,13 @@ - - + + - - + + + diff --git a/backend/Model/DefibrillatorRequest.cs b/backend/Model/DefibrillatorRequest.cs index 5a766b1..483e6ec 100644 --- a/backend/Model/DefibrillatorRequest.cs +++ b/backend/Model/DefibrillatorRequest.cs @@ -14,7 +14,7 @@ public class DefibrillatorRequest public string Description { get; set; } - public string Access { get; set; } + public bool Access { get; set; } public string Operator { get; set; } diff --git a/backend/Model/DefibrillatorRequestV2.cs b/backend/Model/DefibrillatorRequestV2.cs new file mode 100644 index 0000000..4343d25 --- /dev/null +++ b/backend/Model/DefibrillatorRequestV2.cs @@ -0,0 +1,33 @@ +namespace DefikarteBackend.Model +{ + public class DefibrillatorRequestV2 + { + public long? Id { get; set; } + + public double Longitude { get; set; } + + public double Latitude { get; set; } + + public string Location { get; set; } + + public string Reporter { get; set; } + + public string Description { get; set; } + + public string Access { get; set; } + + public string Operator { get; set; } + + public string OperatorPhone { get; set; } + + public string OpeningHours { get; set; } + + public string EmergencyPhone { get; set; } + + public bool Indoor { get; set; } + + public string Source { get; set; } + + public string Level { get; set; } + } +} \ No newline at end of file diff --git a/backend/Model/DefibrillatorResponse.cs b/backend/Model/DefibrillatorResponse.cs new file mode 100644 index 0000000..2a78d3d --- /dev/null +++ b/backend/Model/DefibrillatorResponse.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace DefikarteBackend.Model +{ + public class DefibrillatorResponse + { + public int Latitude { get; set; } + + public int Longitude { get; set; } + + public long Id { get; set; } + + public int Type { get; set; } + + public Dictionary Tags { get; set; } + + public int ChangeSetId { get; set; } + + public bool Visible { get; set; } + + public DateTime TimeStamp { get; set; } + + public int Version { get; set; } + + public int UserId { get; set; } + + public string UserName { get; set; } + } +} \ No newline at end of file diff --git a/backend/Model/FeatureCollection.cs b/backend/Model/FeatureCollection.cs new file mode 100644 index 0000000..ccd3015 --- /dev/null +++ b/backend/Model/FeatureCollection.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace DefikarteBackend.Model +{ + public class FeatureCollection + { + public string Type { get; set; } = "FeatureCollection"; + + public List Features { get; set; } = new List(); + } + + public class Feature + { + public string Type { get; set; } = "Feature"; + + public string Id { get; set; } + + public PointGeometry Geometry { get; set; } + + public Dictionary Properties { get; set; } + } + + public class PointGeometry + { + public string Type { get; set; } = "Point"; + + public double[] Coordinates { get; set; } + } +} \ No newline at end of file diff --git a/backend/SimpleCacheFunction.cs b/backend/SimpleCacheFunction.cs index ba0f9ea..a48113b 100644 --- a/backend/SimpleCacheFunction.cs +++ b/backend/SimpleCacheFunction.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using DefikarteBackend.Cache; using DefikarteBackend.Configuration; @@ -13,11 +15,16 @@ public class SimpleCacheFunction { private readonly ServiceConfiguration _config; private readonly ICacheRepository _cacheRepository; + private readonly IGeoJsonCacheRepository _geoJsonCacheRepository; - public SimpleCacheFunction(ServiceConfiguration config, ICacheRepository cacheRepository) + public SimpleCacheFunction( + ServiceConfiguration config, + ICacheRepository cacheRepository, + IGeoJsonCacheRepository geoJsonCacheRepository) { _config = config; _cacheRepository = cacheRepository; + _geoJsonCacheRepository = geoJsonCacheRepository; } [FunctionName("SimpleCacheFunction")] @@ -29,13 +36,38 @@ public async Task RunAsync([TimerTrigger("0 */15 * * * *", RunOnStartup = true)] try { var response = await overpassApiClient.GetAllDefibrillatorsInSwitzerland(); - var success = await _cacheRepository.TryUpdateCacheAsync(response); - log.LogInformation($"Updated cache sucessful:{success}"); + var cacheV1Task = _cacheRepository.TryUpdateCacheAsync(response); + var cacheV2Task = _geoJsonCacheRepository.TryUpdateCacheAsync(Convert2GeoJson(response)); + + var results = await Task.WhenAll(cacheV1Task, cacheV2Task); + log.LogInformation($"Updated cache sucessful:{results.All(x => x)}"); } catch (Exception ex) { log.LogError(ex.ToString()); } } + + private FeatureCollection Convert2GeoJson(IList nodes) + { + // Convert to GeoJson + var featureCollection = new FeatureCollection + { + Type = "FeatureCollection", + Features = nodes.Select(n => new Feature + { + Id = n.Id, + Type = "Feature", + Geometry = new PointGeometry + { + Type = "Point", + Coordinates = new double[] { n.Lon, n.Lat }, + }, + Properties = n.Tags, + }).ToList(), + }; + + return featureCollection; + } } } \ No newline at end of file diff --git a/backend/Startup.cs b/backend/Startup.cs index 5918914..b112697 100644 --- a/backend/Startup.cs +++ b/backend/Startup.cs @@ -5,6 +5,7 @@ using DefikarteBackend.Configuration; using DefikarteBackend.Model; using Azure.Storage.Blobs; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Abstractions; [assembly: FunctionsStartup(typeof(DefikarteBackend.Startup))] namespace DefikarteBackend @@ -22,6 +23,10 @@ public override void Configure(IFunctionsHostBuilder builder) builder.Services.AddSingleton((s) => serviceConfig); builder.Services.AddTransient>(s => new BlobStorageCacheRepository(container, serviceConfig.BlobStorageBlobName)); + builder.Services.AddTransient(s => + new BlobStorageCacheRepositoryV2(container, serviceConfig.BlobStorageBlobNameV2)); + + builder.Services.AddSingleton(s => new CustomOpenApiConfigurationOptions()); } private IConfigurationRoot LoadConfiguration() diff --git a/backend/Validation/DefibrillatorRequestValidator.cs b/backend/Validation/DefibrillatorRequestValidator.cs index b5fb533..f2e119e 100644 --- a/backend/Validation/DefibrillatorRequestValidator.cs +++ b/backend/Validation/DefibrillatorRequestValidator.cs @@ -15,7 +15,7 @@ public DefibrillatorRequestValidator() RuleFor(x => x.Location).NotEmpty().MaximumLength(200); RuleFor(x => x.Description).MaximumLength(200); RuleFor(x => x.OperatorPhone).Custom((value, context) => PhoneNumberValid(value, context)).When(x => !string.IsNullOrEmpty(x.OperatorPhone)); - RuleFor(x => x.Access).Custom((value, context) => AccessValid(value, context)); + RuleFor(x => x.Access).NotNull(); RuleFor(x => x.Indoor).NotNull(); RuleFor(x => x.EmergencyPhone).NotEmpty().Custom((value, context) => PhoneNumberValid(value, context)); // opening hours validation is missing diff --git a/backend/Validation/DefibrillatorRequestValidatorV2.cs b/backend/Validation/DefibrillatorRequestValidatorV2.cs new file mode 100644 index 0000000..b7d67b7 --- /dev/null +++ b/backend/Validation/DefibrillatorRequestValidatorV2.cs @@ -0,0 +1,71 @@ +using DefikarteBackend.Model; +using FluentValidation; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace DefikarteBackend.Validation +{ + public class DefibrillatorRequestValidatorV2 : AbstractValidator + { + public DefibrillatorRequestValidatorV2() + { + RuleFor(x => x.Latitude).NotEmpty(); + RuleFor(x => x.Longitude).NotEmpty(); + RuleFor(x => x.Reporter).NotEmpty(); + RuleFor(x => x.Location).NotEmpty().MaximumLength(200); + RuleFor(x => x.Description).MaximumLength(200); + RuleFor(x => x.OperatorPhone).Custom((value, context) => PhoneNumberValid(value, context)).When(x => !string.IsNullOrEmpty(x.OperatorPhone)); + RuleFor(x => x.Access).Custom((value, context) => AccessValid(value, context)); + RuleFor(x => x.Indoor).NotNull(); + RuleFor(x => x.EmergencyPhone).NotEmpty().Custom((value, context) => PhoneNumberValid(value, context)); + // opening hours validation is missing + } + + private static void PhoneNumberValid(string phoneNumberRaw, ValidationContext context) + { + if (string.IsNullOrEmpty(phoneNumberRaw)) + { + return; + } + + var result = Regex.Match(phoneNumberRaw, "112|144|117|118|1414"); + if (result.Success) + { + return; + } + + var phoneNumberUtil = PhoneNumbers.PhoneNumberUtil.GetInstance(); + try + { + var value = phoneNumberUtil.Parse(phoneNumberRaw, "CH"); + var valid = + phoneNumberUtil.IsPossibleNumber(value) && + phoneNumberUtil.IsValidNumber(value) && + (phoneNumberUtil.IsValidNumberForRegion(value, "CH") + || phoneNumberUtil.IsValidNumberForRegion(value, "LI")); + + if (!valid) + { + context.AddFailure(context.PropertyName, "Phonenumber not valid"); + } + } + catch (System.Exception) + { + context.AddFailure(context.PropertyName, "Phonenumber not valid"); + } + } + + private static void AccessValid(string access, ValidationContext context) + { + if (string.IsNullOrEmpty(access)) + { + return; + } + + if (!(new List { "yes", "no", "private", "permissive" }).Contains(access)) + { + context.AddFailure(context.PropertyName, "Access not valid"); + } + } + } +} \ No newline at end of file From 6b7ac589888ed552d746f591b49ab447e4c87228 Mon Sep 17 00:00:00 2001 From: MUEHLHEIM Marc Date: Tue, 16 Apr 2024 15:35:02 +0200 Subject: [PATCH 5/6] Extend openApi specification, do not use boolean fields for string fields in osm, add geojson endpoint (v2) --- backend/DefibrillatorFunction.cs | 7 +++++-- backend/DefibrillatorFunctionV2.cs | 9 ++++++--- backend/Model/DefibrillatorRequestV2.cs | 2 +- .../Validation/DefibrillatorRequestValidatorV2.cs | 15 ++++++++++++++- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/backend/DefibrillatorFunction.cs b/backend/DefibrillatorFunction.cs index 0c9a1f4..354a96d 100644 --- a/backend/DefibrillatorFunction.cs +++ b/backend/DefibrillatorFunction.cs @@ -15,7 +15,9 @@ using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.Http; using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums; using Microsoft.Extensions.Logging; +using Microsoft.OpenApi.Models; using Newtonsoft.Json; using OsmSharp; using OsmSharp.IO.API; @@ -35,7 +37,7 @@ public DefibrillatorFunction(ServiceConfiguration config, ICacheRepository), Description = "The OK response")] public async Task GetAll( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "defibrillator")] HttpRequestMessage req, @@ -71,9 +73,10 @@ public async Task GetAll( [FunctionName("Defibrillators_POST")] - [OpenApiOperation(operationId: "CreateDefibrillator_V1")] + [OpenApiOperation(operationId: "CreateDefibrillator_V1", tags: new[] { "Defibrillator-V1" }, Summary = "Create a new defibrillator. [Soon deprecated, use V2]")] [OpenApiRequestBody("application/json", typeof(DefibrillatorRequest))] [OpenApiResponseWithBody(statusCode: HttpStatusCode.Created, contentType: "application/json", bodyType: typeof(DefibrillatorResponse), Description = "The OK response")] + [OpenApiSecurity("Defikarte.ch API-Key", SecuritySchemeType.ApiKey, In = OpenApiSecurityLocationType.Header, Name = "x-functions-key")] public async Task Create( [HttpTrigger(AuthorizationLevel.Function, "Post", Route = "defibrillator")] HttpRequest req, ILogger log) diff --git a/backend/DefibrillatorFunctionV2.cs b/backend/DefibrillatorFunctionV2.cs index d5c4dfc..54b2441 100644 --- a/backend/DefibrillatorFunctionV2.cs +++ b/backend/DefibrillatorFunctionV2.cs @@ -15,7 +15,9 @@ using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.Http; using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes; +using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums; using Microsoft.Extensions.Logging; +using Microsoft.OpenApi.Models; using Newtonsoft.Json; using OsmSharp; using OsmSharp.IO.API; @@ -35,7 +37,7 @@ public DefibrillatorFunctionV2(ServiceConfiguration config, IGeoJsonCacheReposit } [FunctionName("Defibrillators_GETALL_V2")] - [OpenApiOperation(operationId: "GetDefibrillators_V2")] + [OpenApiOperation(operationId: "GetDefibrillators_V2", tags: new[] { "Defibrillator-V2" }, Summary = "Get all defibrillators from switzerland as geojson.")] [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: "application/json", bodyType: typeof(FeatureCollection), Description = "The OK response")] public async Task GetAll( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "v2/defibrillator")] HttpRequestMessage req, @@ -71,9 +73,10 @@ public async Task GetAll( [FunctionName("Defibrillators_POST_V2")] - [OpenApiOperation(operationId: "CreateDefibrillator_V2")] + [OpenApiOperation(operationId: "CreateDefibrillator_V2", tags: new[] {"Defibrillator-V2"}, Summary = "Create a new defibrillator.")] [OpenApiRequestBody("application/json", typeof(DefibrillatorRequestV2))] [OpenApiResponseWithBody(statusCode: HttpStatusCode.Created, contentType: "application/json", bodyType: typeof(DefibrillatorResponse), Description = "The OK response")] + [OpenApiSecurity("Defikarte.ch API-Key", SecuritySchemeType.ApiKey, In = OpenApiSecurityLocationType.Header, Name = "x-functions-key")] public async Task Create( [HttpTrigger(AuthorizationLevel.Function, "Post", Route = "v2/defibrillator")] HttpRequest req, ILogger log) @@ -169,7 +172,7 @@ private static Node CreateNode(DefibrillatorRequestV2 request) "access", request.Access }, { - "indoor", request.Indoor ? "yes" : "no" + "indoor", request.Indoor }, { "description", request.Description diff --git a/backend/Model/DefibrillatorRequestV2.cs b/backend/Model/DefibrillatorRequestV2.cs index 4343d25..e41507c 100644 --- a/backend/Model/DefibrillatorRequestV2.cs +++ b/backend/Model/DefibrillatorRequestV2.cs @@ -24,7 +24,7 @@ public class DefibrillatorRequestV2 public string EmergencyPhone { get; set; } - public bool Indoor { get; set; } + public string Indoor { get; set; } public string Source { get; set; } diff --git a/backend/Validation/DefibrillatorRequestValidatorV2.cs b/backend/Validation/DefibrillatorRequestValidatorV2.cs index b7d67b7..c701a96 100644 --- a/backend/Validation/DefibrillatorRequestValidatorV2.cs +++ b/backend/Validation/DefibrillatorRequestValidatorV2.cs @@ -16,7 +16,7 @@ public DefibrillatorRequestValidatorV2() RuleFor(x => x.Description).MaximumLength(200); RuleFor(x => x.OperatorPhone).Custom((value, context) => PhoneNumberValid(value, context)).When(x => !string.IsNullOrEmpty(x.OperatorPhone)); RuleFor(x => x.Access).Custom((value, context) => AccessValid(value, context)); - RuleFor(x => x.Indoor).NotNull(); + RuleFor(x => x.Indoor).NotNull().NotEmpty().Custom((value, context) => IndoorValid(value, context)); RuleFor(x => x.EmergencyPhone).NotEmpty().Custom((value, context) => PhoneNumberValid(value, context)); // opening hours validation is missing } @@ -67,5 +67,18 @@ private static void AccessValid(string access, ValidationContext context) + { + if (string.IsNullOrEmpty(indoor)) + { + return; + } + + if (!(new List { "yes", "no" }).Contains(indoor)) + { + context.AddFailure(context.PropertyName, "Indoor not valid"); + } + } } } \ No newline at end of file From 0d2d034108c58dabce63500ddc4b9df9f445991b Mon Sep 17 00:00:00 2001 From: MUEHLHEIM Marc Date: Wed, 17 Apr 2024 14:15:52 +0200 Subject: [PATCH 6/6] Remove unused code, change validation context usage --- .../DefibrillatorRequestValidator.cs | 18 ++---------------- .../DefibrillatorRequestValidatorV2.cs | 8 ++++---- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/backend/Validation/DefibrillatorRequestValidator.cs b/backend/Validation/DefibrillatorRequestValidator.cs index f2e119e..aca7bea 100644 --- a/backend/Validation/DefibrillatorRequestValidator.cs +++ b/backend/Validation/DefibrillatorRequestValidator.cs @@ -1,6 +1,5 @@ using DefikarteBackend.Model; using FluentValidation; -using System.Collections.Generic; using System.Text.RegularExpressions; namespace DefikarteBackend.Validation @@ -46,25 +45,12 @@ private static void PhoneNumberValid(string phoneNumberRaw, ValidationContext context) - { - if (string.IsNullOrEmpty(access)) - { - return; - } - - if (!(new List { "yes", "no", "private", "permissive" }).Contains(access)) - { - context.AddFailure(context.PropertyName, "Access not valid"); + context.AddFailure(context.PropertyPath, "Phonenumber not valid"); } } } diff --git a/backend/Validation/DefibrillatorRequestValidatorV2.cs b/backend/Validation/DefibrillatorRequestValidatorV2.cs index c701a96..22cf7bb 100644 --- a/backend/Validation/DefibrillatorRequestValidatorV2.cs +++ b/backend/Validation/DefibrillatorRequestValidatorV2.cs @@ -46,12 +46,12 @@ private static void PhoneNumberValid(string phoneNumberRaw, ValidationContext { "yes", "no", "private", "permissive" }).Contains(access)) { - context.AddFailure(context.PropertyName, "Access not valid"); + context.AddFailure(context.PropertyPath, "Access not valid"); } } @@ -77,7 +77,7 @@ private static void IndoorValid(string indoor, ValidationContext { "yes", "no" }).Contains(indoor)) { - context.AddFailure(context.PropertyName, "Indoor not valid"); + context.AddFailure(context.PropertyPath, "Indoor not valid"); } } }