Gathering infra
All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 46s
Deploy Promiscuity Character API / deploy (push) Successful in 45s
Deploy Promiscuity Inventory API / deploy (push) Successful in 46s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m3s
k8s smoke test / test (push) Successful in 7s
All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 46s
Deploy Promiscuity Character API / deploy (push) Successful in 45s
Deploy Promiscuity Inventory API / deploy (push) Successful in 46s
Deploy Promiscuity Locations API / deploy (push) Successful in 1m3s
k8s smoke test / test (push) Successful in 7s
This commit is contained in:
parent
a2a4d48de5
commit
9ba725d207
@ -3,6 +3,10 @@ using LocationsApi.Services;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace LocationsApi.Controllers;
|
namespace LocationsApi.Controllers;
|
||||||
|
|
||||||
@ -11,10 +15,14 @@ namespace LocationsApi.Controllers;
|
|||||||
public class LocationsController : ControllerBase
|
public class LocationsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly LocationStore _locations;
|
private readonly LocationStore _locations;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
public LocationsController(LocationStore locations)
|
public LocationsController(LocationStore locations, IHttpClientFactory httpClientFactory, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_locations = locations;
|
_locations = locations;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_configuration = configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@ -85,4 +93,67 @@ public class LocationsController : ControllerBase
|
|||||||
|
|
||||||
return Ok("Updated");
|
return Ok("Updated");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/gather")]
|
||||||
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
|
public async Task<IActionResult> Gather(string id, [FromBody] GatherResourceRequest req)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(req.CharacterId))
|
||||||
|
return BadRequest("characterId required");
|
||||||
|
if (string.IsNullOrWhiteSpace(req.ResourceKey))
|
||||||
|
return BadRequest("resourceKey required");
|
||||||
|
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var allowAnyOwner = User.IsInRole("SUPER");
|
||||||
|
var gather = await _locations.GatherResourceAsync(id, req.CharacterId, req.ResourceKey, userId, allowAnyOwner);
|
||||||
|
if (gather.Status == GatherStatus.LocationNotFound)
|
||||||
|
return NotFound("Location not found");
|
||||||
|
if (gather.Status == GatherStatus.CharacterNotFound)
|
||||||
|
return NotFound("Character not found");
|
||||||
|
if (gather.Status == GatherStatus.Forbidden)
|
||||||
|
return Forbid();
|
||||||
|
if (gather.Status == GatherStatus.Invalid)
|
||||||
|
return BadRequest("Character is not at the target location");
|
||||||
|
if (gather.Status == GatherStatus.ResourceNotFound)
|
||||||
|
return NotFound("Resource not found at location");
|
||||||
|
if (gather.Status == GatherStatus.ResourceDepleted)
|
||||||
|
return Conflict("Resource is depleted");
|
||||||
|
|
||||||
|
var inventoryBaseUrl = (_configuration["Services:InventoryApiBaseUrl"] ?? "http://localhost:5003").TrimEnd('/');
|
||||||
|
var token = Request.Headers.Authorization.ToString();
|
||||||
|
var grantBody = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
itemKey = gather.ResourceKey,
|
||||||
|
quantity = gather.QuantityGranted
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
using var request = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"{inventoryBaseUrl}/api/inventory/by-owner/character/{req.CharacterId}/grant");
|
||||||
|
request.Content = new StringContent(grantBody, Encoding.UTF8, "application/json");
|
||||||
|
if (!string.IsNullOrWhiteSpace(token))
|
||||||
|
request.Headers.Authorization = AuthenticationHeaderValue.Parse(token);
|
||||||
|
|
||||||
|
using var response = await client.SendAsync(request);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await _locations.RestoreGatheredResourceAsync(id, gather.ResourceKey, gather.QuantityGranted);
|
||||||
|
return StatusCode((int)response.StatusCode, responseBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new GatherResourceResponse
|
||||||
|
{
|
||||||
|
LocationId = id,
|
||||||
|
CharacterId = req.CharacterId,
|
||||||
|
ResourceKey = gather.ResourceKey,
|
||||||
|
QuantityGranted = gather.QuantityGranted,
|
||||||
|
RemainingQuantity = gather.RemainingQuantity,
|
||||||
|
InventoryResponseJson = responseBody
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,13 @@ Inbound JSON documents
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
`coord` cannot be updated.
|
`coord` cannot be updated.
|
||||||
|
- GatherResourceRequest (`POST /api/locations/{id}/gather`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"characterId": "string (Character ObjectId)",
|
||||||
|
"resourceKey": "wood"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Stored documents (MongoDB)
|
Stored documents (MongoDB)
|
||||||
- Location
|
- Location
|
||||||
@ -32,6 +39,26 @@ Stored documents (MongoDB)
|
|||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0
|
"y": 0
|
||||||
},
|
},
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"itemKey": "wood",
|
||||||
|
"remainingQuantity": 100,
|
||||||
|
"gatherQuantity": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
"createdUtc": "string (ISO-8601 datetime)"
|
"createdUtc": "string (ISO-8601 datetime)"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Outbound JSON documents
|
||||||
|
- GatherResourceResponse (`POST /api/locations/{id}/gather`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"locationId": "string (ObjectId)",
|
||||||
|
"characterId": "string (ObjectId)",
|
||||||
|
"resourceKey": "wood",
|
||||||
|
"quantityGranted": 3,
|
||||||
|
"remainingQuantity": 97,
|
||||||
|
"inventoryResponseJson": "string (raw InventoryApi response JSON)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace LocationsApi.Models;
|
||||||
|
|
||||||
|
public class GatherResourceRequest
|
||||||
|
{
|
||||||
|
public string CharacterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ResourceKey { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
16
microservices/LocationsApi/Models/GatherResourceResponse.cs
Normal file
16
microservices/LocationsApi/Models/GatherResourceResponse.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
namespace LocationsApi.Models;
|
||||||
|
|
||||||
|
public class GatherResourceResponse
|
||||||
|
{
|
||||||
|
public string LocationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string CharacterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ResourceKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int QuantityGranted { get; set; }
|
||||||
|
|
||||||
|
public int RemainingQuantity { get; set; }
|
||||||
|
|
||||||
|
public string InventoryResponseJson { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@ -15,6 +15,9 @@ public class Location
|
|||||||
[BsonElement("coord")]
|
[BsonElement("coord")]
|
||||||
public required Coord Coord { get; set; }
|
public required Coord Coord { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("resources")]
|
||||||
|
public List<LocationResource> Resources { get; set; } = [];
|
||||||
|
|
||||||
[BsonElement("createdUtc")]
|
[BsonElement("createdUtc")]
|
||||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
15
microservices/LocationsApi/Models/LocationResource.cs
Normal file
15
microservices/LocationsApi/Models/LocationResource.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace LocationsApi.Models;
|
||||||
|
|
||||||
|
public class LocationResource
|
||||||
|
{
|
||||||
|
[BsonElement("itemKey")]
|
||||||
|
public string ItemKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("remainingQuantity")]
|
||||||
|
public int RemainingQuantity { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("gatherQuantity")]
|
||||||
|
public int GatherQuantity { get; set; } = 1;
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ using System.Text;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
// DI
|
// DI
|
||||||
builder.Services.AddSingleton<LocationStore>();
|
builder.Services.AddSingleton<LocationStore>();
|
||||||
|
|||||||
@ -8,6 +8,7 @@ public class LocationStore
|
|||||||
{
|
{
|
||||||
private readonly IMongoCollection<Location> _col;
|
private readonly IMongoCollection<Location> _col;
|
||||||
private readonly IMongoCollection<BsonDocument> _rawCol;
|
private readonly IMongoCollection<BsonDocument> _rawCol;
|
||||||
|
private readonly IMongoCollection<CharacterDocument> _characters;
|
||||||
private const string CoordIndexName = "coord_x_1_coord_y_1";
|
private const string CoordIndexName = "coord_x_1_coord_y_1";
|
||||||
|
|
||||||
public LocationStore(IConfiguration cfg)
|
public LocationStore(IConfiguration cfg)
|
||||||
@ -20,6 +21,7 @@ public class LocationStore
|
|||||||
EnsureLocationSchema(db, collectionName);
|
EnsureLocationSchema(db, collectionName);
|
||||||
_col = db.GetCollection<Location>(collectionName);
|
_col = db.GetCollection<Location>(collectionName);
|
||||||
_rawCol = db.GetCollection<BsonDocument>(collectionName);
|
_rawCol = db.GetCollection<BsonDocument>(collectionName);
|
||||||
|
_characters = db.GetCollection<CharacterDocument>("Characters");
|
||||||
|
|
||||||
EnsureCoordIndexes();
|
EnsureCoordIndexes();
|
||||||
|
|
||||||
@ -34,11 +36,11 @@ public class LocationStore
|
|||||||
"$jsonSchema", new BsonDocument
|
"$jsonSchema", new BsonDocument
|
||||||
{
|
{
|
||||||
{ "bsonType", "object" },
|
{ "bsonType", "object" },
|
||||||
{ "required", new BsonArray { "name", "coord", "createdUtc" } },
|
{ "required", new BsonArray { "name", "coord", "createdUtc" } },
|
||||||
{
|
{
|
||||||
"properties", new BsonDocument
|
"properties", new BsonDocument
|
||||||
{
|
{
|
||||||
{ "name", new BsonDocument { { "bsonType", "string" } } },
|
{ "name", new BsonDocument { { "bsonType", "string" } } },
|
||||||
{
|
{
|
||||||
"coord", new BsonDocument
|
"coord", new BsonDocument
|
||||||
{
|
{
|
||||||
@ -52,10 +54,31 @@ public class LocationStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ "createdUtc", new BsonDocument { { "bsonType", "date" } } }
|
{
|
||||||
}
|
"resources", new BsonDocument
|
||||||
}
|
{
|
||||||
|
{ "bsonType", new BsonArray { "array", "null" } },
|
||||||
|
{
|
||||||
|
"items", new BsonDocument
|
||||||
|
{
|
||||||
|
{ "bsonType", "object" },
|
||||||
|
{ "required", new BsonArray { "itemKey", "remainingQuantity", "gatherQuantity" } },
|
||||||
|
{
|
||||||
|
"properties", new BsonDocument
|
||||||
|
{
|
||||||
|
{ "itemKey", new BsonDocument { { "bsonType", "string" } } },
|
||||||
|
{ "remainingQuantity", new BsonDocument { { "bsonType", "int" }, { "minimum", 0 } } },
|
||||||
|
{ "gatherQuantity", new BsonDocument { { "bsonType", "int" }, { "minimum", 1 } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ "createdUtc", new BsonDocument { { "bsonType", "date" } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -102,6 +125,57 @@ public class LocationStore
|
|||||||
return result.ModifiedCount > 0;
|
return result.ModifiedCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record GatherResult(GatherStatus Status, string ResourceKey = "", int QuantityGranted = 0, int RemainingQuantity = 0);
|
||||||
|
|
||||||
|
public async Task<GatherResult> GatherResourceAsync(string locationId, string characterId, string resourceKey, string userId, bool allowAnyOwner)
|
||||||
|
{
|
||||||
|
var normalizedKey = resourceKey.Trim().ToLowerInvariant();
|
||||||
|
var location = await _col.Find(l => l.Id == locationId).FirstOrDefaultAsync();
|
||||||
|
if (location is null)
|
||||||
|
return new GatherResult(GatherStatus.LocationNotFound);
|
||||||
|
|
||||||
|
var character = await _characters.Find(c => c.Id == characterId).FirstOrDefaultAsync();
|
||||||
|
if (character is null)
|
||||||
|
return new GatherResult(GatherStatus.CharacterNotFound);
|
||||||
|
if (!allowAnyOwner && character.OwnerUserId != userId)
|
||||||
|
return new GatherResult(GatherStatus.Forbidden);
|
||||||
|
if (character.Coord.X != location.Coord.X || character.Coord.Y != location.Coord.Y)
|
||||||
|
return new GatherResult(GatherStatus.Invalid);
|
||||||
|
|
||||||
|
var resource = location.Resources.FirstOrDefault(r => NormalizeItemKey(r.ItemKey) == normalizedKey);
|
||||||
|
if (resource is null)
|
||||||
|
return new GatherResult(GatherStatus.ResourceNotFound);
|
||||||
|
if (resource.RemainingQuantity <= 0)
|
||||||
|
return new GatherResult(GatherStatus.ResourceDepleted);
|
||||||
|
|
||||||
|
var quantityGranted = Math.Min(resource.GatherQuantity, resource.RemainingQuantity);
|
||||||
|
var filter = Builders<Location>.Filter.And(
|
||||||
|
Builders<Location>.Filter.Eq(l => l.Id, locationId),
|
||||||
|
Builders<Location>.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == resource.ItemKey && r.RemainingQuantity >= quantityGranted)
|
||||||
|
);
|
||||||
|
var update = Builders<Location>.Update.Inc("resources.$.remainingQuantity", -quantityGranted);
|
||||||
|
var result = await _col.UpdateOneAsync(filter, update);
|
||||||
|
if (result.ModifiedCount == 0)
|
||||||
|
return new GatherResult(GatherStatus.ResourceDepleted);
|
||||||
|
|
||||||
|
return new GatherResult(
|
||||||
|
GatherStatus.Ok,
|
||||||
|
resource.ItemKey,
|
||||||
|
quantityGranted,
|
||||||
|
resource.RemainingQuantity - quantityGranted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RestoreGatheredResourceAsync(string locationId, string resourceKey, int quantity)
|
||||||
|
{
|
||||||
|
var normalizedKey = NormalizeItemKey(resourceKey);
|
||||||
|
var filter = Builders<Location>.Filter.And(
|
||||||
|
Builders<Location>.Filter.Eq(l => l.Id, locationId),
|
||||||
|
Builders<Location>.Filter.ElemMatch(l => l.Resources, r => r.ItemKey == normalizedKey)
|
||||||
|
);
|
||||||
|
var update = Builders<Location>.Update.Inc("resources.$.remainingQuantity", quantity);
|
||||||
|
await _col.UpdateOneAsync(filter, update);
|
||||||
|
}
|
||||||
|
|
||||||
private void EnsureCoordIndexes()
|
private void EnsureCoordIndexes()
|
||||||
{
|
{
|
||||||
var indexes = _rawCol.Indexes.List().ToList();
|
var indexes = _rawCol.Indexes.List().ToList();
|
||||||
@ -149,12 +223,17 @@ public class LocationStore
|
|||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var origin = new Location
|
var origin = new Location
|
||||||
{
|
{
|
||||||
Name = "Origin",
|
Name = "Origin",
|
||||||
Coord = new Coord { X = 0, Y = 0 },
|
Coord = new Coord { X = 0, Y = 0 },
|
||||||
CreatedUtc = DateTime.UtcNow
|
Resources =
|
||||||
};
|
[
|
||||||
|
new LocationResource { ItemKey = "wood", RemainingQuantity = 100, GatherQuantity = 3 },
|
||||||
|
new LocationResource { ItemKey = "grass", RemainingQuantity = 500, GatherQuantity = 10 }
|
||||||
|
],
|
||||||
|
CreatedUtc = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -164,5 +243,30 @@ public class LocationStore
|
|||||||
{
|
{
|
||||||
// Another instance seeded it first.
|
// Another instance seeded it first.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
[MongoDB.Bson.Serialization.Attributes.BsonIgnoreExtraElements]
|
||||||
|
private class CharacterDocument
|
||||||
|
{
|
||||||
|
[MongoDB.Bson.Serialization.Attributes.BsonId]
|
||||||
|
[MongoDB.Bson.Serialization.Attributes.BsonRepresentation(MongoDB.Bson.BsonType.ObjectId)]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
public string OwnerUserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Coord Coord { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum GatherStatus
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
LocationNotFound,
|
||||||
|
CharacterNotFound,
|
||||||
|
Forbidden,
|
||||||
|
Invalid,
|
||||||
|
ResourceNotFound,
|
||||||
|
ResourceDepleted
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
{
|
{
|
||||||
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5002" } } },
|
"Services": {
|
||||||
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
"InventoryApiBaseUrl": "http://localhost:5003"
|
||||||
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
},
|
||||||
"Logging": { "LogLevel": { "Default": "Information" } }
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5002" } } },
|
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5002" } } },
|
||||||
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
||||||
|
"Services": { "InventoryApiBaseUrl": "https://pinv.ranaze.com" },
|
||||||
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||||
"Logging": { "LogLevel": { "Default": "Information" } },
|
"Logging": { "LogLevel": { "Default": "Information" } },
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user