API and database rework

This commit is contained in:
Laura Hausmann 2023-02-08 21:34:52 +01:00
parent 9dfd45927c
commit 67e18ee192
Signed by: zotan
GPG key ID: D044E84C5BE01605
13 changed files with 244 additions and 154 deletions

View file

@ -22,6 +22,7 @@ lib_deps =
arduino-libraries/NTPClient@^3.2.1
mcxiaoke/ESPDateTime@^1.0.4
arduino12/rdm6300@^2.0.0
bblanchon/ArduinoJson@^6.20.0
check_tool = clangtidy
check_flags =
clangtidy: --checks=*,-cppcoreguidelines-avoid-magic-numbers,-readability-magic-numbers,-modernize-macro-to-enum

View file

@ -163,7 +163,7 @@ void loop() {
updateOLED(u8g2, state, lastStatusText);
tone(PIN_BUZZER, NOTE_A5, 25);
tone(PIN_BUZZER, NOTE_NONE, 150);
lastStatusText = cardTransaction(wifi, http, apiUrl, scannedCardId, "1.50");
lastStatusText = cardTransaction(wifi, http, apiUrl, scannedCardId, "-150");
if (lastStatusText.startsWith("S:")) {
tone(PIN_BUZZER, NOTE_C7, 650);
lastStatusText += "";

View file

@ -1,4 +1,5 @@
#include <Arduino.h>
#include <ArduinoJson.h>
#include <WiFiClient.h>
#include <HTTPClient.h>
@ -15,10 +16,23 @@ String uint32AsHexString(uint32_t input) {
return byteArrayAsHexString(reinterpret_cast<byte *>(&input), sizeof input);
}
String CentsToEuros(long cents){
char euros[16];
sprintf(euros, "%s%ld,%02ld", cents < 0 ? "-" : "", abs(cents / 100), abs(cents % 100));
return euros;
}
unsigned long cooldownSecondsRemaining(unsigned long timeout, unsigned long timer) {
return (timeout - (millis() - timer)) / 1000 + 1;
}
String getIdempotencyKey(){
const int len = 16;
byte buf[len];
esp_fill_random(buf, len);
return byteArrayAsHexString(buf, len);
}
String splitString(String data, char separator, int index) {
int found = 0;
int strIndex[] = {0, -1};
@ -36,13 +50,36 @@ String splitString(String data, char separator, int index) {
String cardLink(WiFiClient *wifi, HTTPClient *http, String apiUrl, String cardId) {
String finalRequestUrl = apiUrl + "/api/card/link?card=" + cardId;
String finalRequestUrl = apiUrl + "/api/card/" + cardId + "/link";
http->begin(*wifi, finalRequestUrl.c_str());
int httpResponseCode = http->GET();
if (httpResponseCode == 200) {
http->addHeader("Content-Type", "application/json");
int httpResponseCode = http->PUT("");
if (httpResponseCode == 304)
return "E:Already registered.";
if (httpResponseCode == 200 || httpResponseCode == 404) {
String payload = http->getString();
http->end();
return payload;
Serial.println(payload);
StaticJsonDocument<256> json;
DeserializationError error = deserializeJson(json, payload.c_str());
if (error) {
Serial.println(error.c_str());
return String("E:JsonError:") + httpResponseCode;
}
const char* status = json["status"];
if (strcmp(status, "success") != 0) {
const char* message = json["message"];
return String("E:") + message;
}
JsonObject data = json["data"];
const char* nickname = data["nickname"];
long balance = data["balance"];
return String("S:") + nickname + ":" + CentsToEuros(balance);
}
http->end();
if (httpResponseCode > 0) {
@ -52,13 +89,34 @@ String cardLink(WiFiClient *wifi, HTTPClient *http, String apiUrl, String cardId
}
String cardBalance(WiFiClient *wifi, HTTPClient *http, String apiUrl, String cardId) {
String finalRequestUrl = apiUrl + "/api/card/balance?card=" + cardId;
String finalRequestUrl = apiUrl + "/api/card/" + cardId + "/balance";
http->begin(*wifi, finalRequestUrl.c_str());
http->addHeader("Content-Type", "application/json");
int httpResponseCode = http->GET();
if (httpResponseCode == 200) {
if (httpResponseCode == 200 || httpResponseCode == 404) {
String payload = http->getString();
http->end();
return payload;
Serial.println(payload);
StaticJsonDocument<256> json;
DeserializationError error = deserializeJson(json, payload.c_str());
if (error) {
Serial.println(error.c_str());
return "E:JsonError";
}
const char* status = json["status"];
if (strcmp(status, "success") != 0) {
const char* message = json["message"];
return String("E:") + message;
}
JsonObject data = json["data"];
const char* nickname = data["nickname"];
long balance = data["balance"];
return String("S:") + nickname + ":" + CentsToEuros(balance);
}
http->end();
if (httpResponseCode > 0) {
@ -68,13 +126,34 @@ String cardBalance(WiFiClient *wifi, HTTPClient *http, String apiUrl, String car
}
String cardTransaction(WiFiClient *wifi, HTTPClient *http, String apiUrl, String cardId, String amount) {
String finalRequestUrl = apiUrl + "/api/card/transaction?card=" + cardId + "&amount=" + amount;
String idempotencyKey = getIdempotencyKey();
String finalRequestUrl = apiUrl + "/api/card/" + cardId + "/transaction/" + idempotencyKey + "?amount=" + amount;
http->begin(*wifi, finalRequestUrl.c_str());
int httpResponseCode = http->GET();
if (httpResponseCode == 200) {
http->addHeader("Content-Type", "application/json");
int httpResponseCode = http->PUT("");
if (httpResponseCode == 200 || httpResponseCode == 404 || httpResponseCode == 412) {
String payload = http->getString();
http->end();
return payload;
Serial.println(payload);
StaticJsonDocument<256> json;
DeserializationError error = deserializeJson(json, payload.c_str());
if (error) {
return "E:JsonError";
}
const char* status = json["status"];
if (strcmp(status, "success") != 0) {
const char* message = json["message"];
return String("E:") + message;
}
JsonObject data = json["data"];
const char* nickname = data["nickname"];
long balance = data["balance"];
return String("S:") + nickname + ":" + CentsToEuros(balance);
}
http->end();
if (httpResponseCode > 0) {

View file

@ -1,6 +1,7 @@
using System.Reflection;
using AfRApay.Web.Backend;
using LinqToDB.Data;
using Swashbuckle.AspNetCore.Filters;
DataConnection.DefaultSettings = new Database.Settings();
Migrations.RunMigrations();
@ -11,7 +12,9 @@ builder.Services.AddRazorPages();
builder.Services.AddSwaggerGen(options => {
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
options.ExampleFilters();
});
builder.Services.AddSwaggerExamplesFromAssemblies(Assembly.GetEntryAssembly());
#if (DEBUG)
builder.Services.AddControllers().AddRazorRuntimeCompilation();

View file

@ -8,6 +8,6 @@ namespace AfRApay.Web.Backend.Tables;
public class User {
[Column(Name = "ID"), PrimaryKey, Identity, NotNull] public int Id { get; set; }
[Column(Name = "Nickname"), NotNull] public string Nickname { get; set; }
[Column(Name = "Balance")] public decimal Balance { get; set; }
[Column(Name = "Balance")] public int Balance { get; set; }
[Column(Name = "LastIdempotencyKey")] public string? LastIdempotencyKey { get; set; }
}

View file

@ -1,21 +0,0 @@
using AfRApay.Web.Backend;
using AfRApay.Web.Backend.Tables;
using LinqToDB;
using Microsoft.AspNetCore.Mvc;
namespace AfRApay.Web.Controllers;
[ApiController, Route("/api/card/balance")]
public class CardBalance : Controller {
[HttpGet]
public string Get([FromQuery] string card) {
var db = new Database.DbConn();
if (db.Cards.Any(p => p.CardId == card)) {
var userId = db.Cards.First(p => p.CardId == card).UserId;
var user = db.Users.First(p => p.Id == userId);
return $"S:{user.Nickname}:{user.Balance:N2}";
}
return "E:Unknown card.";
}
}

View file

@ -0,0 +1,137 @@
using AfRApay.Web.Backend;
using AfRApay.Web.Backend.Tables;
using AfRApay.Web.Controllers.Schema;
using LinqToDB;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Filters;
namespace AfRApay.Web.Controllers;
[ApiController]
public class CardController : Controller {
/// <summary>
/// Links the given card to the user currently in link mode.
/// </summary>
/// <param name="card">The ID of the card</param>
/// <response code="200">Returns 200 if the link succeeded</response>
/// <response code="304">Returns 304 if the card was already linked</response>
/// <response code="404">Returns 404 if no link process is active</response>
[HttpPut]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))]
[ProducesResponseType(StatusCodes.Status304NotModified)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
[SwaggerResponseExample(StatusCodes.Status200OK, typeof(UserUpdatedExample))]
[SwaggerResponseExample(StatusCodes.Status404NotFound, typeof(ErrorNoActiveLinkProcessExample))]
[Route("/api/card/{card}/link")]
public async Task<IActionResult> Link(string card) {
var db = new Database.DbConn();
if (db.Cards.Any(p => p.CardId == card)) {
return StatusCode(StatusCodes.Status304NotModified);
}
var linkFlag = db.Config.FirstOrDefault(p => p.Name == "link");
if (string.IsNullOrWhiteSpace(linkFlag?.Value)) {
return NotFound(new ErrorResponse("No active link process"));
}
var lTimeFlag = db.Config.FirstOrDefault(p => p.Name == "lTime");
if (string.IsNullOrWhiteSpace(lTimeFlag?.Value)) {
return NotFound(new ErrorResponse("No active link process"));
}
if (DateTime.UtcNow - DateTime.Parse(lTimeFlag.Value) > TimeSpan.FromMinutes(5)) {
return NotFound(new ErrorResponse("No active link process"));
}
var user = db.Users.First(p => p.Id == int.Parse(linkFlag.Value));
linkFlag.Value = "";
await db.InsertAsync(new Card { CardId = card, UserId = user.Id });
await db.UpdateAsync(linkFlag);
return Ok(new UserResponse(user));
}
/// <summary>
/// Creates a transaction that changes the balance of the the user the card is linked to by the specified amount.
/// </summary>
/// <param name="card">The ID of the card</param>
/// <param name="ik">Random string (idempotency key) which is consistent across request retries</param>
/// <param name="amount">Positive or negative number of cents representing the relative change in balance</param>
/// <response code="200">Returns 200 if the transaction succeeded</response>
/// <response code="404">Returns 404 if the card isn't linked to any account</response>
/// <response code="412">Returns 412 if the transaction failed because the balance would be out of range after the transaction</response>
[HttpPut]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed, Type = typeof(ErrorResponse))]
[SwaggerResponseExample(200, typeof(UserUpdatedExample))]
[SwaggerResponseExample(404, typeof(ErrorUnknownCardExample))]
[SwaggerResponseExample(412, typeof(ErrorBalanceOutOfRangeExample))]
[Route("/api/card/{card}/transaction/{ik}")]
public async Task<IActionResult> Transaction(string card, string ik, [FromQuery] int amount) {
var db = new Database.DbConn();
if (db.Cards.Any(p => p.CardId == card)) {
var userId = db.Cards.First(p => p.CardId == card).UserId;
var user = db.Users.First(p => p.Id == userId);
if (ik == "" || ik != user.LastIdempotencyKey) {
user.LastIdempotencyKey = ik;
switch (user.Balance + amount) {
case < -9999: return StatusCode(412, new ErrorResponse("Balance too low!"));
case > 99999: return StatusCode(412, new ErrorResponse("Balance too high!"));
}
user.Balance += amount;
}
await db.UpdateAsync(user);
return Ok(new UserResponse(user));
}
return NotFound(new ErrorResponse("Unknown card."));
}
/// <summary>
/// Returns the balance of the the user the card is linked to.
/// </summary>
/// <param name="card">The ID of the card</param>
/// <response code="200">Returns 200 if the request succeeded</response>
/// <response code="404">Returns 404 if the card isn't linked to any account</response>
[HttpGet]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
[SwaggerResponseExample(200, typeof(UserUpdatedExample))]
[SwaggerResponseExample(404, typeof(ErrorUnknownCardExample))]
[Route("/api/card/{card}/balance")]
public IActionResult Balance(string card) {
var db = new Database.DbConn();
if (db.Cards.Any(p => p.CardId == card)) {
var userId = db.Cards.First(p => p.CardId == card).UserId;
var user = db.Users.First(p => p.Id == userId);
return Ok(new UserResponse(user));
}
return NotFound(new ErrorResponse("Unknown card."));
}
private class UserUpdatedExample : IExamplesProvider<UserResponse> {
public UserResponse GetExamples() => new(new User { Id = 123, Nickname = "testman", Balance = 2550, LastIdempotencyKey = "5a6c94aa"});
}
private class ErrorUnknownCardExample : IExamplesProvider<ErrorResponse> {
public ErrorResponse GetExamples() => new("Unknown card");
}
private class ErrorBalanceOutOfRangeExample : IExamplesProvider<ErrorResponse> {
public ErrorResponse GetExamples() => new("Balance out of range");
}
private class ErrorNoActiveLinkProcessExample : IExamplesProvider<ErrorResponse> {
public ErrorResponse GetExamples() => new("No active link process");
}
}

View file

@ -1,40 +0,0 @@
using AfRApay.Web.Backend;
using AfRApay.Web.Backend.Tables;
using LinqToDB;
using Microsoft.AspNetCore.Mvc;
namespace AfRApay.Web.Controllers;
[ApiController, Route("/api/card/link")]
public class CardLink : Controller {
[HttpGet]
public string Get([FromQuery] string card) {
var db = new Database.DbConn();
if (db.Cards.Any(p => p.CardId == card)) {
return "E:Already registered.";
}
var linkFlag = db.Config.FirstOrDefault(p => p.Name == "link");
if (string.IsNullOrWhiteSpace(linkFlag?.Value)) {
return "E:No link flag set.";
}
var lTimeFlag = db.Config.FirstOrDefault(p => p.Name == "lTime");
if (string.IsNullOrWhiteSpace(lTimeFlag?.Value)) {
return "E:No link flag set.";
}
if (DateTime.UtcNow - DateTime.Parse(lTimeFlag.Value) > TimeSpan.FromMinutes(5)) {
return "E:Link expired, try again.";
}
var user = db.Users.First(p => p.Id == int.Parse(linkFlag.Value));
linkFlag.Value = "";
db.Insert(new Card { CardId = card, UserId = user.Id });
db.Update(linkFlag);
return $"S:Reg. -> {user.Nickname}";
}
}

View file

@ -1,32 +0,0 @@
using AfRApay.Web.Backend;
using LinqToDB;
using Microsoft.AspNetCore.Mvc;
namespace AfRApay.Web.Controllers;
[ApiController, Route("/api/card/transaction")]
public class CardTransaction : Controller {
[HttpGet]
public string Get([FromQuery] string card, [FromQuery] decimal amount, [FromQuery] string ik) {
var db = new Database.DbConn();
if (db.Cards.Any(p => p.CardId == card)) {
var userId = db.Cards.First(p => p.CardId == card).UserId;
var user = db.Users.First(p => p.Id == userId);
// If an idempotency key is given, disregard duplicate requests.
if (ik == "" || ik != user.LastIdempotencyKey) {
if (user.Balance - amount < -50) {
return "E:Balance too low!";
}
user.Balance -= amount;
user.LastIdempotencyKey = ik;
db.Update(user);
}
return $"S:{user.Nickname}: {user.Balance:N2}";
}
return "E:Unknown card.";
}
}

View file

@ -1,37 +0,0 @@
using AfRApay.Web.Backend;
using AfRApay.Web.Controllers.Schema;
using LinqToDB;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Filters;
namespace AfRApay.Web.Controllers;
[ApiController]
public class UserController : Controller {
/// <summary>
/// Renames the specified user.
/// </summary>
/// <param name="uid">The ID of the user to be renamed</param>
/// <param name="newName">The new name of the user</param>
/// <response code="200">Returns 200 if user was renamed successfully</response>
/// <response code="400">Returns 400 if newName isn't unique</response>
/// <response code="404">Returns 404 if no user matching the UID was found</response>
[HttpPut]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
//[SwaggerResponseExample(400, typeof(new )]
[Route("/api/user/{uid:int}/name")]
public async Task<IActionResult> Rename(int uid, [FromQuery] string newName) {
var db = new Database.DbConn();
if (db.Users.Any(p => p.Id == uid)) {
var user = db.Users.First(p => p.Id == uid);
user.Nickname = newName;
await db.UpdateAsync(user);
return new OkObjectResult(new UserResponse(user));
}
return NotFound(new ErrorResponse("Unknown user"));
}
}

View file

@ -59,7 +59,7 @@
<b>@user.Nickname</b>
</td>
<td>
@($"{user.Balance:C}")
@($"{user.Balance/100M:C}")
</td>
<td>
<b>@db.Cards.Count(p => p.UserId == user.Id)</b> cards linked.
@ -67,9 +67,9 @@
<td> <!-- Displayed when in big layout -->
<div class="d-none d-md-flex btn-group btn-group-lg" role="group">
<!-- Make sure these buttons match the small/mobile layout ones below -->
<a class="btn px-3 btn-danger" href="/UpdateBalance/@user.Id/-1.50">-1.50&euro;</a>
<a class="btn px-3 btn-success" href="/UpdateBalance/@user.Id/5">+5&euro;</a>
<a class="btn px-3 btn-success" href="/UpdateBalance/@user.Id/10">+10&euro;</a>
<a class="btn px-3 btn-danger" href="/Transaction/@user.Id/-150">-1.50&euro;</a>
<a class="btn px-3 btn-success" href="/Transaction/@user.Id/500">+5&euro;</a>
<a class="btn px-3 btn-success" href="/Transaction/@user.Id/1000">+10&euro;</a>
<a class="btn px-2 btn-primary" href="/EditUser/@user.Id">Edit</a>
</div>
<!-- Displayed when in compact/phone layout -->
@ -80,9 +80,9 @@
<div class="dropdown-menu p-1" aria-labelledby="dropdownMenuLink" style="min-width: max-content;"> <!-- Inline CSS, sets minimum width of drop down to maxium size of content -->
<div class="d-grid gap-1">
<!-- Make sure these buttons match the big layout ones above-->
<a class="btn btn-lg btn-danger" href="/UpdateBalance/@user.Id/-1.50">-1.50&euro;</a>
<a class="btn btn-lg btn-success" href="/UpdateBalance/@user.Id/5">+5&euro;</a>
<a class="btn btn-lg btn-success" href="/UpdateBalance/@user.Id/10">+10&euro;</a>
<a class="btn btn-lg btn-danger" href="/UpdateBalance/@user.Id/-150">-1.50&euro;</a>
<a class="btn btn-lg btn-success" href="/UpdateBalance/@user.Id/500">+5&euro;</a>
<a class="btn btn-lg btn-success" href="/UpdateBalance/@user.Id/1000">+10&euro;</a>
<a class="btn btn-lg btn-primary" href="/EditUser/@user.Id">Edit</a>
</div>
</div>

View file

@ -1,4 +1,4 @@
@page "{id:int}/{amount:decimal}"
@page "{id:int}/{amount:int}"
@model UpdateBalanceModel
@{
Layout = null;

View file

@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AfRApay.Web.Pages;
public class UpdateBalanceModel : PageModel {
public void OnGet(int id, decimal amount) {
public void OnGet(int id, int amount) {
var db = new Database.DbConn();
var user = db.Users.FirstOrDefault(p => p.Id == id);
if (user == null) {
@ -15,8 +15,8 @@ public class UpdateBalanceModel : PageModel {
}
switch (user.Balance + amount) {
case < -50: throw new ConstraintException("Balance too low!");
case > 999: throw new ConstraintException("Balance too high!");
case < -9999: throw new ConstraintException("Balance too low!");
case > 99999: throw new ConstraintException("Balance too high!");
}
user.Balance += amount;