From 0e3a4c7d28ca3f78d25156630294ac3c2750c4af Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 21 Oct 2026 12:14:12 +0100 Subject: [PATCH] add api defaults and scalar --- Directory.Packages.props | 13 +- .../DataTransferObjects/Configuration.cs | 12 +- .../ConfigurationResponse.cs | 12 +- .../DataTransferObjects/HostAddress.cs | 7 +- .../DataTransferObjects/LogMessage.cs | 8 +- .../MobileConfiguration.csproj | 1 + ...nfigurationExamplesOperationTransformer.cs | 119 ++++++++++ MobileConfiguration/Program.cs | 69 +++++- README.md | 4 +- docs/api.md | 207 ++++++++++++++++++ 10 files changed, 430 insertions(+), 22 deletions(-) create mode 100644 MobileConfiguration/OpenApi/MobileConfigurationExamplesOperationTransformer.cs create mode 100644 docs/api.md diff --git a/Directory.Packages.props b/Directory.Packages.props index 7f240b2..6982489 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,16 +5,16 @@ - + - - - - - + + + + + @@ -22,5 +22,6 @@ + \ No newline at end of file diff --git a/MobileConfiguration/DataTransferObjects/Configuration.cs b/MobileConfiguration/DataTransferObjects/Configuration.cs index b1ae582..c89a7dd 100644 --- a/MobileConfiguration/DataTransferObjects/Configuration.cs +++ b/MobileConfiguration/DataTransferObjects/Configuration.cs @@ -1,18 +1,28 @@ -namespace MobileConfiguration.DataTransferObjects; +using System.ComponentModel; +namespace MobileConfiguration.DataTransferObjects; + +[Description("Configuration settings associated with a Transaction Mobile device.")] public class Configuration { + [Description("Client identifier used by the mobile device.")] public string ClientId { get; set; } + [Description("Client secret used by the mobile device. Treat this value as sensitive.")] public string ClientSecret { get; set; } + [Description("Identifier of the mobile device.")] public string DeviceIdentifier { get; set; } + [Description("Whether the mobile device should install updates automatically.")] public bool EnableAutoUpdates { get; set; } + [Description("Service URI values available to the mobile device.")] public List HostAddresses { get; set; } + [Description("Unique identifier for this configuration.")] public string Id { get; set; } + [Description("Minimum severity level written by the mobile client. Enum values are numeric in JSON.")] public LoggingLevel LogLevel { get; set; } } \ No newline at end of file diff --git a/MobileConfiguration/DataTransferObjects/ConfigurationResponse.cs b/MobileConfiguration/DataTransferObjects/ConfigurationResponse.cs index 4640574..87bc1ca 100644 --- a/MobileConfiguration/DataTransferObjects/ConfigurationResponse.cs +++ b/MobileConfiguration/DataTransferObjects/ConfigurationResponse.cs @@ -1,17 +1,27 @@ -namespace MobileConfiguration.DataTransferObjects; +using System.ComponentModel; +namespace MobileConfiguration.DataTransferObjects; + +[Description("Configuration returned for a Transaction Mobile device. Includes the client secret.")] public class ConfigurationResponse { + [Description("Client identifier used by the mobile device.")] public string ClientId { get; set; } + [Description("Client secret used by the mobile device. Treat this value as sensitive.")] public string ClientSecret { get; set; } + [Description("Identifier of the mobile device.")] public string DeviceIdentifier { get; set; } + [Description("Whether the mobile device should install updates automatically.")] public bool EnableAutoUpdates { get; set; } + [Description("Service URI values available to the mobile device.")] public List HostAddresses { get; set; } + [Description("Unique identifier for this configuration.")] public string Id { get; set; } + [Description("Minimum severity level written by the mobile client. Enum values are numeric in JSON.")] public LoggingLevel LogLevel { get; set; } } \ No newline at end of file diff --git a/MobileConfiguration/DataTransferObjects/HostAddress.cs b/MobileConfiguration/DataTransferObjects/HostAddress.cs index 17e8c65..991d2a5 100644 --- a/MobileConfiguration/DataTransferObjects/HostAddress.cs +++ b/MobileConfiguration/DataTransferObjects/HostAddress.cs @@ -1,8 +1,13 @@ -namespace MobileConfiguration.DataTransferObjects; +using System.ComponentModel; +namespace MobileConfiguration.DataTransferObjects; + +[Description("A service type and the URI where the mobile client can reach it.")] public class HostAddress { + [Description("Service represented by this address. Enum values are numeric in JSON.")] public ServiceType ServiceType { get; set; } + [Description("URI for the service endpoint.")] public string Uri { get; set; } } \ No newline at end of file diff --git a/MobileConfiguration/DataTransferObjects/LogMessage.cs b/MobileConfiguration/DataTransferObjects/LogMessage.cs index e3d603c..fea43ca 100644 --- a/MobileConfiguration/DataTransferObjects/LogMessage.cs +++ b/MobileConfiguration/DataTransferObjects/LogMessage.cs @@ -1,8 +1,14 @@ -namespace MobileConfiguration.DataTransferObjects; +using System.ComponentModel; +namespace MobileConfiguration.DataTransferObjects; + +[Description("A log event submitted by a mobile client.")] public class LogMessage { + [Description("Timestamp associated with the log event.")] public DateTime EntryDateTime { get; set; } + [Description("Log severity label supplied by the client.")] public String LogLevel { get; set; } + [Description("Text of the log event.")] public String Message { get; set; } } \ No newline at end of file diff --git a/MobileConfiguration/MobileConfiguration.csproj b/MobileConfiguration/MobileConfiguration.csproj index 135f365..33befbf 100644 --- a/MobileConfiguration/MobileConfiguration.csproj +++ b/MobileConfiguration/MobileConfiguration.csproj @@ -36,6 +36,7 @@ + diff --git a/MobileConfiguration/OpenApi/MobileConfigurationExamplesOperationTransformer.cs b/MobileConfiguration/OpenApi/MobileConfigurationExamplesOperationTransformer.cs new file mode 100644 index 0000000..43e49b8 --- /dev/null +++ b/MobileConfiguration/OpenApi/MobileConfigurationExamplesOperationTransformer.cs @@ -0,0 +1,119 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace MobileConfiguration.OpenApi; + +/// +/// Adds representative request and response payloads to the generated OpenAPI document. +/// +internal sealed class MobileConfigurationExamplesOperationTransformer : IOpenApiOperationTransformer +{ + private const string ConfigurationExample = """ + { + "clientId": "mobile-client", + "clientSecret": "example-secret", + "deviceIdentifier": "device-123", + "enableAutoUpdates": true, + "hostAddresses": [ + { "serviceType": 2, "uri": "https://transactions.example.test" } + ], + "id": "device-123", + "logLevel": 3 + } + """; + + private const string ConfigurationResponseExample = """ + { + "clientId": "mobile-client", + "clientSecret": "example-secret", + "deviceIdentifier": "device-123", + "enableAutoUpdates": true, + "hostAddresses": [ + { "serviceType": 2, "uri": "https://transactions.example.test" } + ], + "id": "device-123", + "logLevel": 3 + } + """; + + private const string CreateSuccessExample = """ + { + "status": 0, + "isSuccess": true, + "isFailed": false, + "message": "Configuration created successfully.", + "errors": [] + } + """; + + private const string CreateFailureExample = """ + { + "status": 6, + "isSuccess": false, + "isFailed": true, + "message": "Failed to create configuration: persistence failed.", + "errors": [] + } + """; + + private const string LogMessagesExample = """ + [ + { + "entryDateTime": "2026-10-21 09:30:00", + "logLevel": "Information", + "message": "Mobile client started" + } + ] + """; + + public Task TransformAsync(OpenApiOperation operation, + OpenApiOperationTransformerContext context, + CancellationToken cancellationToken) + { + string path = context.Description.RelativePath?.Trim('/') ?? string.Empty; + string? requestExample = (context.Description.HttpMethod, path) switch + { + ("POST", "api/transactionmobile/configuration") => ConfigurationExample, + ("PUT", "api/transactionmobile/configuration/{id}") => ConfigurationExample, + ("POST", "api/transactionmobile/logging") => LogMessagesExample, + _ => null + }; + + if (requestExample is not null + && operation.RequestBody is OpenApiRequestBody requestBody + && requestBody.Content is not null + && requestBody.Content.TryGetValue("application/json", out OpenApiMediaType? requestMediaType)) + { + requestMediaType.Example = JsonNode.Parse(requestExample); + } + + string? successResponseExample = (context.Description.HttpMethod, path) switch + { + ("POST", "api/transactionmobile/configuration") => CreateSuccessExample, + ("GET", "api/transactionmobile/configuration/{id}") => ConfigurationResponseExample, + _ => null + }; + + SetResponseExample(operation, "200", successResponseExample); + + if (context.Description.HttpMethod == "POST" && path == "api/transactionmobile/configuration") + { + SetResponseExample(operation, "400", CreateFailureExample); + } + + return Task.CompletedTask; + } + + private static void SetResponseExample(OpenApiOperation operation, string statusCode, string? example) + { + if (example is not null + && operation.Responses is not null + && operation.Responses.TryGetValue(statusCode, out IOpenApiResponse? response) + && response.Content is not null + && response.Content.TryGetValue("application/json", out OpenApiMediaType? mediaType)) + { + mediaType.Example = JsonNode.Parse(example); + } + } +} diff --git a/MobileConfiguration/Program.cs b/MobileConfiguration/Program.cs index 766c6e1..40630ff 100644 --- a/MobileConfiguration/Program.cs +++ b/MobileConfiguration/Program.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.EntityFrameworkCore; using MobileConfiguration.Database; +using MobileConfiguration.DataTransferObjects; using MobileConfiguration.Repository; using NLog; using NLog.Extensions.Logging; @@ -17,6 +18,9 @@ using System.Reflection; using System.Text.Json; using Microsoft.Extensions.Diagnostics.HealthChecks; +using SimpleResults; +using TransactionProcessing.ApiDefaults; +using MobileConfiguration.OpenApi; using ILogger = Microsoft.Extensions.Logging.ILogger; using LogLevel = Microsoft.Extensions.Logging.LogLevel; @@ -154,6 +158,15 @@ builder.Services.AddHealthMonitoringRegistration(configuration); +builder.Services.AddApiDefaults(options => { + options.Title = "Mobile Configuration"; + options.Description = "API to maintain all aspects of mobile device configuration"; +}); +builder.Services.AddOpenApi(options => +{ + options.AddOperationTransformer(new MobileConfigurationExamplesOperationTransformer()); +}); + var app = builder.Build(); var serialiser = app.Services.GetRequiredService(); @@ -161,9 +174,7 @@ app.UseMiddleware(); -app.UseSwagger(); -app.UseSwaggerUI(); - +app.UseApiDefaults(); // Configure the HTTP request pipeline. app.UseAuthorization(); @@ -173,24 +184,60 @@ // Minimal API endpoints (handler pattern) -app.MapPost("/api/TransactionMobileConfiguration", MobileConfiguration.Handlers.TransactionMobileConfigurationHandler.PostConfiguration); -app.MapGet("/api/TransactionMobileConfiguration/{id}", MobileConfiguration.Handlers.TransactionMobileConfigurationHandler.GetConfiguration); -app.MapPut("/api/TransactionMobileConfiguration/{id}", MobileConfiguration.Handlers.TransactionMobileConfigurationHandler.PutConfiguration); - -app.MapPost("/api/TransactionMobileLogging", MobileConfiguration.Handlers.TransactionMobileLoggingHandler.PostLogging); +String configBaseRoute = "/api/transactionmobile/configuration"; + +app.MapPost(configBaseRoute, MobileConfiguration.Handlers.TransactionMobileConfigurationHandler.PostConfiguration) + .WithName("CreateTransactionMobileConfiguration") + .WithTags("Mobile Configuration") + .WithSummary("Create a mobile configuration") + .WithDescription("Creates a Transaction Mobile configuration and returns the persistence result. A failed persistence operation is returned as 400 Bad Request.") + .Accepts("application/json") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest); +app.MapGet($"{configBaseRoute}/{{id}}", MobileConfiguration.Handlers.TransactionMobileConfigurationHandler.GetConfiguration) + .WithName("GetTransactionMobileConfiguration") + .WithTags("Mobile Configuration") + .WithSummary("Get a mobile configuration") + .WithDescription("Returns the Transaction Mobile configuration for the supplied identifier. The response contains the configured client secret.") + .Produces(StatusCodes.Status200OK); +app.MapPut($"{configBaseRoute}/{{id}}", MobileConfiguration.Handlers.TransactionMobileConfigurationHandler.PutConfiguration) + .WithName("UpsertTransactionMobileConfiguration") + .WithTags("Mobile Configuration") + .WithSummary("Create or update a mobile configuration") + .WithDescription("Updates the Transaction Mobile configuration for the supplied identifier, or creates it if it does not exist. For an existing record, the path identifier selects the record.") + .Accepts("application/json") + .Produces(StatusCodes.Status200OK); + +String loggingBaseRoute = "/api/transactionmobile/logging"; + +app.MapPost(loggingBaseRoute, MobileConfiguration.Handlers.TransactionMobileLoggingHandler.PostLogging) + .WithName("SubmitTransactionMobileLogs") + .WithTags("Mobile Logging") + .WithSummary("Submit mobile log messages") + .WithDescription("Writes the submitted log messages to the service logger. The messages are not stored in the configuration database.") + .Accepts>("application/json") + .Produces(StatusCodes.Status200OK); app.MapHealthChecks("health", new HealthCheckOptions { Predicate = _ => true, ResponseWriter = Shared.HealthChecks.HealthCheckMiddleware.WriteResponse - }); + }) + .WithName("HealthCheck") + .WithTags("Health") + .WithSummary("Check service and dependency health") + .WithDescription("Returns health-check details for the service and its configured dependencies."); app.MapHealthChecks("healthui", new HealthCheckOptions { Predicate = _ => true, ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse - }); + }) + .WithName("HealthCheckUi") + .WithTags("Health") + .WithSummary("Get health details for the monitoring UI") + .WithDescription("Returns health-check data formatted for the health monitoring UI."); InitializeDatabase(app).Wait(CancellationToken.None); @@ -222,4 +269,4 @@ public static void ConfigureMinimalApi(JsonSerializerOptions serializerOptions) serializerOptions.WriteIndented = defaultOptions.WriteIndented; serializerOptions.Converters.Add(new DateTimeSpaceConverter()); } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 8aae717..8263a9b 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# MobileConfiguration \ No newline at end of file +# MobileConfiguration + +Open Scalar at `/scalar` for the interactive, generated API reference. See [the API and DTO reference](docs/api.md) for endpoint routes, request and response shapes, enum values, and examples. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..43e71e3 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,207 @@ +# Mobile Configuration API + +This document describes the HTTP endpoints and JSON data transfer objects exposed by the service. The service listens on port `9200` by default. The interactive Scalar reference is available at `/scalar`; it is generated from the OpenAPI metadata on the endpoints and DTO properties. + +## Conventions + +- Request and response bodies use JSON. Property names are camel case (for example, `clientId`). +- Enum values are represented as JSON integers, using the numeric values shown below. +- The API startup configures authorization middleware but does not declare an authentication scheme or endpoint-specific authorization policy. Any protection supplied by hosting infrastructure is outside this contract. +- Configuration responses include `clientSecret`. Treat them as sensitive and protect the API and its traffic accordingly. +- The endpoint behavior below reflects the handlers and repository implementation. Error response bodies are produced by shared exception handling and are not specified here. + +## Endpoints + +### Create a configuration + +`POST /api/transactionmobile/configuration` + +Creates a Transaction Mobile configuration. + +**Request body:** [`Configuration`](#configuration) + +**Responses:** + +- `200 OK` with the repository result when the create operation succeeds. +- `400 Bad Request` with the repository result when persistence reports failure. + +Example request: + +```json +{ + "clientId": "mobile-client", + "clientSecret": "replace-with-secret", + "deviceIdentifier": "device-123", + "enableAutoUpdates": true, + "hostAddresses": [ + { "serviceType": 2, "uri": "https://transactions.example.com" } + ], + "id": "device-123", + "logLevel": 3 +} +``` + +Example successful response (`200 OK`): + +```json +{ + "status": 0, + "isSuccess": true, + "isFailed": false, + "message": "Configuration created successfully.", + "errors": [] +} +``` + +Example persistence failure (`400 Bad Request`): + +```json +{ + "status": 6, + "isSuccess": false, + "isFailed": true, + "message": "Failed to create configuration: persistence failed.", + "errors": [] +} +``` + +### Get a configuration + +`GET /api/transactionmobile/configuration/{id}` + +Returns the Transaction Mobile configuration whose ID matches the path value. + +**Path parameters:** + +| Name | Type | Description | +| --- | --- | --- | +| `id` | string | Configuration identifier. | + +**Responses:** + +- `200 OK` with a [`ConfigurationResponse`](#configurationresponse) body. +- If no matching configuration exists, the repository throws a not-found exception; the exact HTTP status and body depend on the shared exception handler. + +Example response: + +```json +{ + "clientId": "mobile-client", + "clientSecret": "example-secret", + "deviceIdentifier": "device-123", + "enableAutoUpdates": true, + "hostAddresses": [ + { "serviceType": 2, "uri": "https://transactions.example.test" } + ], + "id": "device-123", + "logLevel": 3 +} +``` + +### Create or update a configuration + +`PUT /api/transactionmobile/configuration/{id}` + +Updates the configuration matching `id`, or creates one if it does not exist. The configuration type is Transaction Mobile. + +**Path parameters:** + +| Name | Type | Description | +| --- | --- | --- | +| `id` | string | Identifier of the configuration to update or create. | + +**Request body:** [`Configuration`](#configuration). The body `id` is used when a new record is created; for an existing record, the path `id` selects the record and the body ID is not used to change its key. + +**Responses:** + +- `200 OK` with an empty body after persistence completes. +- If saving fails or no database is available, the shared exception handler determines the error response. + +### Submit mobile log messages + +`POST /api/transactionmobile/logging` + +Writes the submitted log-message list to the service logger. This endpoint does not persist the entries through the configuration database. + +**Request body:** array of [`LogMessage`](#logmessage) objects. + +Example request: + +```json +[ + { + "entryDateTime": "2026-10-21 09:30:00", + "logLevel": "Information", + "message": "Mobile client started" + } +] +``` + +**Responses:** `200 OK` with an empty body. + +### Health checks + +- `GET /health` returns health-check details in the service's shared response format. +- `GET /healthui` returns the Health Checks UI response format. + +## DTOs + +### Configuration + +| Property | JSON type | Description | +| --- | --- | --- | +| `clientId` | string | Client identifier. | +| `clientSecret` | string | Client secret. Sensitive value. | +| `deviceIdentifier` | string | Device identifier associated with the configuration. | +| `enableAutoUpdates` | boolean | Whether automatic updates are enabled. | +| `hostAddresses` | array of [`HostAddress`](#hostaddress) | Service endpoints available to the mobile client. | +| `id` | string | Configuration identifier. | +| `logLevel` | integer | [`LoggingLevel`](#logginglevel) value. | + +### ConfigurationResponse + +The response DTO returned by the GET endpoint. It has the same properties and types as [`Configuration`](#configuration), including `clientSecret`. + +### HostAddress + +| Property | JSON type | Description | +| --- | --- | --- | +| `serviceType` | integer | [`ServiceType`](#servicetype) value. | +| `uri` | string | URI for the service. | + +### LogMessage + +| Property | JSON type | Description | +| --- | --- | --- | +| `entryDateTime` | string (date-time) | Timestamp associated with the log entry. | +| `logLevel` | string | Caller-provided level label. | +| `message` | string | Log message text. | + +## Enums + +### LoggingLevel + +| Name | Value | +| --- | ---: | +| `Fatal` | 0 | +| `Error` | 1 | +| `Warning` | 2 | +| `Information` | 3 | +| `Debug` | 4 | +| `Trace` | 5 | + +### ServiceType + +| Name | Value | +| --- | ---: | +| `EstateManagement` | 0 | +| `Security` | 1 | +| `TransactionProcessorAcl` | 2 | +| `VoucherManagementAcl` | 3 | + +## Known contract limitations + +- DTO properties are not annotated with validation rules, so this document does not promise field-level validation or required-field error details. +- `PUT` updates the values on the existing record but does not change its identifier. +- The GET response mapping currently preserves `TransactionProcessorAcl` host types; other stored host types map to `Security` in the response. +- The create endpoint returns the shared repository result object. The OpenAPI schema and example describe its current serialized shape.