Adding inventory microservice
All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 46s
Deploy Promiscuity Character API / deploy (push) Successful in 46s
Deploy Promiscuity Locations API / deploy (push) Successful in 46s
k8s smoke test / test (push) Successful in 7s

This commit is contained in:
Zeeshaun 2026-03-15 10:21:49 -05:00
parent 9809869cbe
commit 9a7d6544ef
27 changed files with 1483 additions and 7 deletions

View File

@ -2,7 +2,7 @@ extends Node3D
const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters"
@export var tile_size := 4.0
@export var tile_size := 16.0
@export var block_height := 1.0
@export_range(1, 8, 1) var tile_radius := 3
@export var tracked_node_path: NodePath

View File

@ -0,0 +1,248 @@
using InventoryApi.Models;
using InventoryApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace InventoryApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class InventoryController : ControllerBase
{
private readonly InventoryStore _inventory;
public InventoryController(InventoryStore inventory)
{
_inventory = inventory;
}
[HttpGet("by-owner/{ownerType}/{ownerId}")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> GetByOwner(string ownerType, string ownerId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var access = await _inventory.ResolveOwnerAsync(ownerType, ownerId, userId, User.IsInRole("SUPER"));
if (!access.IsSupported)
return BadRequest("Unsupported ownerType");
if (!access.Exists)
return NotFound();
if (!access.IsAuthorized)
return Forbid();
var items = await _inventory.GetByOwnerAsync(access.OwnerType, access.OwnerId);
return Ok(new InventoryOwnerResponse
{
OwnerType = access.OwnerType,
OwnerId = access.OwnerId,
Items = items.Select(InventoryItemResponse.FromModel).ToList()
});
}
[HttpPost("by-owner/{ownerType}/{ownerId}/grant")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Grant(string ownerType, string ownerId, [FromBody] GrantInventoryItemRequest req)
{
if (string.IsNullOrWhiteSpace(req.ItemKey))
return BadRequest("itemKey required");
if (req.Quantity <= 0)
return BadRequest("quantity must be greater than 0");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var access = await _inventory.ResolveOwnerAsync(ownerType, ownerId, userId, User.IsInRole("SUPER"));
if (!access.IsSupported)
return BadRequest("Unsupported ownerType");
if (!access.Exists)
return NotFound();
if (!access.IsAuthorized)
return Forbid();
var items = await _inventory.GrantAsync(access, req);
return Ok(new InventoryOwnerResponse
{
OwnerType = access.OwnerType,
OwnerId = access.OwnerId,
Items = items.Select(InventoryItemResponse.FromModel).ToList()
});
}
[HttpPost("by-owner/{ownerType}/{ownerId}/move")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Move(string ownerType, string ownerId, [FromBody] MoveInventoryItemRequest req)
{
if (string.IsNullOrWhiteSpace(req.ItemId))
return BadRequest("itemId required");
if (req.ToSlot < 0)
return BadRequest("toSlot must be >= 0");
if (req.Quantity is <= 0)
return BadRequest("quantity must be greater than 0");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var access = await _inventory.ResolveOwnerAsync(ownerType, ownerId, userId, User.IsInRole("SUPER"));
if (!access.IsSupported)
return BadRequest("Unsupported ownerType");
if (!access.Exists)
return NotFound();
if (!access.IsAuthorized)
return Forbid();
var result = await _inventory.MoveAsync(access, req);
return result.Status switch
{
InventoryMutationStatus.ItemNotFound => NotFound(),
InventoryMutationStatus.Invalid => BadRequest("Invalid move"),
InventoryMutationStatus.Conflict => Conflict("Target slot is not available"),
_ => Ok(new InventoryOwnerResponse
{
OwnerType = access.OwnerType,
OwnerId = access.OwnerId,
Items = result.Items.Select(InventoryItemResponse.FromModel).ToList()
})
};
}
[HttpPost("transfer")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Transfer([FromBody] TransferInventoryItemRequest req)
{
if (string.IsNullOrWhiteSpace(req.ItemId))
return BadRequest("itemId required");
if (string.IsNullOrWhiteSpace(req.FromOwnerType) || string.IsNullOrWhiteSpace(req.FromOwnerId))
return BadRequest("from owner required");
if (string.IsNullOrWhiteSpace(req.ToOwnerType) || string.IsNullOrWhiteSpace(req.ToOwnerId))
return BadRequest("to owner required");
if (req.ToSlot is < 0)
return BadRequest("toSlot must be >= 0");
if (req.Quantity is <= 0)
return BadRequest("quantity must be greater than 0");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var fromAccess = await _inventory.ResolveOwnerAsync(req.FromOwnerType, req.FromOwnerId, userId, User.IsInRole("SUPER"));
if (!fromAccess.IsSupported)
return BadRequest("Unsupported fromOwnerType");
if (!fromAccess.Exists)
return NotFound("Source owner not found");
if (!fromAccess.IsAuthorized)
return Forbid();
var toAccess = await _inventory.ResolveOwnerAsync(req.ToOwnerType, req.ToOwnerId, userId, User.IsInRole("SUPER"));
if (!toAccess.IsSupported)
return BadRequest("Unsupported toOwnerType");
if (!toAccess.Exists)
return NotFound("Target owner not found");
if (!toAccess.IsAuthorized)
return Forbid();
var result = await _inventory.TransferAsync(fromAccess, toAccess, req);
return result.Status switch
{
InventoryMutationStatus.ItemNotFound => NotFound(),
InventoryMutationStatus.Invalid => BadRequest("Invalid transfer"),
InventoryMutationStatus.Conflict => Conflict("Target slot is not available"),
_ => Ok(new TransferInventoryResponse
{
MovedItemId = req.ItemId,
FromOwnerType = fromAccess.OwnerType,
FromOwnerId = fromAccess.OwnerId,
ToOwnerType = toAccess.OwnerType,
ToOwnerId = toAccess.OwnerId,
FromItems = result.Items.Select(InventoryItemResponse.FromModel).Where(x => x.OwnerType == fromAccess.OwnerType && x.OwnerId == fromAccess.OwnerId).ToList(),
ToItems = result.Items.Select(InventoryItemResponse.FromModel).Where(x => x.OwnerType == toAccess.OwnerType && x.OwnerId == toAccess.OwnerId).ToList()
})
};
}
[HttpPost("items/{itemId}/consume")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Consume(string itemId, [FromBody] ConsumeInventoryItemRequest req)
{
if (req.Quantity <= 0)
return BadRequest("quantity must be greater than 0");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var result = await _inventory.ConsumeAsync(itemId, req.Quantity, userId, User.IsInRole("SUPER"));
return result.Status switch
{
InventoryMutationStatus.ItemNotFound => NotFound(),
InventoryMutationStatus.Invalid => BadRequest("Invalid consume request"),
InventoryMutationStatus.Conflict => Conflict(),
_ => Ok(new InventoryOwnerResponse
{
OwnerType = result.OwnerType,
OwnerId = result.OwnerId,
Items = result.Items.Select(InventoryItemResponse.FromModel).ToList()
})
};
}
[HttpPost("items/{itemId}/equip")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Equip(string itemId, [FromBody] EquipInventoryItemRequest req)
{
if (string.IsNullOrWhiteSpace(req.OwnerId))
return BadRequest("ownerId required");
if (string.IsNullOrWhiteSpace(req.EquipmentSlot))
return BadRequest("equipmentSlot required");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var result = await _inventory.EquipAsync(itemId, req.OwnerId, req.EquipmentSlot, userId, User.IsInRole("SUPER"));
return result.Status switch
{
InventoryMutationStatus.ItemNotFound => NotFound(),
InventoryMutationStatus.Invalid => BadRequest("Invalid equip request"),
InventoryMutationStatus.Conflict => Conflict("Equipment slot is not available"),
_ => Ok(new InventoryOwnerResponse
{
OwnerType = result.OwnerType,
OwnerId = result.OwnerId,
Items = result.Items.Select(InventoryItemResponse.FromModel).ToList()
})
};
}
[HttpPost("items/{itemId}/unequip")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Unequip(string itemId, [FromBody] UnequipInventoryItemRequest req)
{
if (string.IsNullOrWhiteSpace(req.OwnerId))
return BadRequest("ownerId required");
if (req.PreferredSlot is < 0)
return BadRequest("preferredSlot must be >= 0");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var result = await _inventory.UnequipAsync(itemId, req.OwnerId, req.PreferredSlot, userId, User.IsInRole("SUPER"));
return result.Status switch
{
InventoryMutationStatus.ItemNotFound => NotFound(),
InventoryMutationStatus.Invalid => BadRequest("Invalid unequip request"),
InventoryMutationStatus.Conflict => Conflict("Inventory slot is not available"),
_ => Ok(new InventoryOwnerResponse
{
OwnerType = result.OwnerType,
OwnerId = result.OwnerId,
Items = result.Items.Select(InventoryItemResponse.FromModel).ToList()
})
};
}
}

View File

@ -0,0 +1,186 @@
# InventoryApi document shapes
This service stores one MongoDB document per inventory item record.
Inbound JSON documents
- GrantInventoryItemRequest (`POST /api/inventory/by-owner/{ownerType}/{ownerId}/grant`)
```json
{
"itemKey": "string",
"quantity": 1,
"preferredSlot": 0
}
```
`preferredSlot` is optional. If omitted, the service finds a valid destination slot.
- MoveInventoryItemRequest (`POST /api/inventory/by-owner/{ownerType}/{ownerId}/move`)
```json
{
"itemId": "uuid-string",
"toSlot": 1,
"quantity": 1
}
```
`quantity` is optional. If omitted, move the full stack.
- TransferInventoryItemRequest (`POST /api/inventory/transfer`)
```json
{
"itemId": "uuid-string",
"fromOwnerType": "character",
"fromOwnerId": "string",
"toOwnerType": "location",
"toOwnerId": "string",
"toSlot": 0,
"quantity": 1
}
```
`toSlot` and `quantity` are optional. If omitted, the service finds a valid destination and transfers the full stack.
- ConsumeInventoryItemRequest (`POST /api/inventory/items/{itemId}/consume`)
```json
{
"quantity": 1
}
```
- EquipInventoryItemRequest (`POST /api/inventory/items/{itemId}/equip`)
```json
{
"ownerId": "string",
"equipmentSlot": "weapon"
}
```
Only valid for items currently owned by a character inventory.
- UnequipInventoryItemRequest (`POST /api/inventory/items/{itemId}/unequip`)
```json
{
"ownerId": "string",
"preferredSlot": 0
}
```
Only valid for items currently equipped by a character.
Stored documents (MongoDB)
- InventoryItem
```json
{
"id": "string (UUID)",
"itemKey": "wood",
"quantity": 12,
"ownerType": "character",
"ownerId": "string (ObjectId or stable external id)",
"ownerUserId": "string (ObjectId from auth token, null for public world owners)",
"slot": 0,
"equippedSlot": null,
"createdUtc": "string (ISO-8601 datetime)",
"updatedUtc": "string (ISO-8601 datetime)"
}
```
Equipped character item example:
```json
{
"id": "string (UUID)",
"itemKey": "pistol",
"quantity": 1,
"ownerType": "character",
"ownerId": "string (Character ObjectId)",
"ownerUserId": "string (ObjectId from auth token)",
"slot": null,
"equippedSlot": "weapon",
"createdUtc": "string (ISO-8601 datetime)",
"updatedUtc": "string (ISO-8601 datetime)"
}
```
Location stack example:
```json
{
"id": "string (UUID)",
"itemKey": "wood",
"quantity": 12,
"ownerType": "location",
"ownerId": "string (Location ObjectId)",
"ownerUserId": null,
"slot": 3,
"equippedSlot": null,
"createdUtc": "string (ISO-8601 datetime)",
"updatedUtc": "string (ISO-8601 datetime)"
}
```
Outbound JSON documents
- InventoryItemResponse
```json
{
"id": "string (UUID)",
"itemKey": "wood",
"quantity": 12,
"ownerType": "character",
"ownerId": "string",
"slot": 0,
"equippedSlot": null,
"updatedUtc": "string (ISO-8601 datetime)"
}
```
- InventoryOwnerResponse (`GET /api/inventory/by-owner/{ownerType}/{ownerId}`)
```json
{
"ownerType": "character",
"ownerId": "string",
"items": [
{
"id": "string (UUID)",
"itemKey": "wood",
"quantity": 12,
"ownerType": "character",
"ownerId": "string",
"slot": 0,
"equippedSlot": null,
"updatedUtc": "string (ISO-8601 datetime)"
}
]
}
```
- TransferInventoryResponse (`POST /api/inventory/transfer`)
```json
{
"movedItemId": "string (UUID)",
"fromOwnerType": "character",
"fromOwnerId": "string",
"toOwnerType": "location",
"toOwnerId": "string",
"fromItems": [],
"toItems": []
}
```
Validation rules
- `ownerType` must be a supported container type
- `ownerId` must map to an existing owning entity where applicable
- non-`SUPER` callers may only access owned character items unless explicit gameplay rules allow a world container read/write
- `quantity` must be greater than `0`
- non-stackable items must have `quantity = 1`
- equipped items must have `slot = null`
- unequipped bag items must have `equippedSlot = null`
- an item must not have both `slot` and `equippedSlot` populated
- slot occupancy must be unique for `(ownerType, ownerId, slot)` where `slot != null`
- equipment occupancy must be unique for `(ownerType, ownerId, equippedSlot)` where `equippedSlot != null`
Recommended indexes
- unique on `id`
- index on `(ownerType, ownerId)`
- unique on `(ownerType, ownerId, slot)` for bag slots
- unique on `(ownerType, ownerId, equippedSlot)` for equipped slots
- index on `itemKey`
Behavior rules
- moving a full non-stackable item should update its owner and slot in place
- moving part of a stack should split the stack and create a new item record with a new UUID for the moved quantity
- moving into a compatible stack should merge quantities and delete or reduce the source record
- cross-owner transfer should be transactional when it mutates multiple records
- auctions should reference `itemId` values directly instead of copying item state into the auction document

View File

@ -0,0 +1,19 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["InventoryApi.csproj", "./"]
RUN dotnet restore "InventoryApi.csproj"
COPY . .
RUN dotnet publish "InventoryApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080 \
ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080
ENTRYPOINT ["dotnet", "InventoryApi.dll"]

View File

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
<PackageReference Include="MongoDB.Driver" Version="3.4.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
namespace InventoryApi.Models;
public class ConsumeInventoryItemRequest
{
public int Quantity { get; set; } = 1;
}

View File

@ -0,0 +1,8 @@
namespace InventoryApi.Models;
public class EquipInventoryItemRequest
{
public string OwnerId { get; set; } = string.Empty;
public string EquipmentSlot { get; set; } = string.Empty;
}

View File

@ -0,0 +1,10 @@
namespace InventoryApi.Models;
public class GrantInventoryItemRequest
{
public string ItemKey { get; set; } = string.Empty;
public int Quantity { get; set; } = 1;
public int? PreferredSlot { get; set; }
}

View File

@ -0,0 +1,38 @@
using MongoDB.Bson.Serialization.Attributes;
namespace InventoryApi.Models;
public class InventoryItem
{
[BsonId]
public string Id { get; set; } = Guid.NewGuid().ToString();
[BsonElement("itemKey")]
public string ItemKey { get; set; } = string.Empty;
[BsonElement("quantity")]
public int Quantity { get; set; } = 1;
[BsonElement("ownerType")]
public string OwnerType { get; set; } = string.Empty;
[BsonElement("ownerId")]
public string OwnerId { get; set; } = string.Empty;
[BsonElement("ownerUserId")]
public string? OwnerUserId { get; set; }
[BsonElement("slot")]
[BsonIgnoreIfNull]
public int? Slot { get; set; }
[BsonElement("equippedSlot")]
[BsonIgnoreIfNull]
public string? EquippedSlot { get; set; }
[BsonElement("createdUtc")]
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
[BsonElement("updatedUtc")]
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,32 @@
namespace InventoryApi.Models;
public class InventoryItemResponse
{
public string Id { get; set; } = string.Empty;
public string ItemKey { get; set; } = string.Empty;
public int Quantity { get; set; }
public string OwnerType { get; set; } = string.Empty;
public string OwnerId { get; set; } = string.Empty;
public int? Slot { get; set; }
public string? EquippedSlot { get; set; }
public DateTime UpdatedUtc { get; set; }
public static InventoryItemResponse FromModel(InventoryItem item) => new()
{
Id = item.Id,
ItemKey = item.ItemKey,
Quantity = item.Quantity,
OwnerType = item.OwnerType,
OwnerId = item.OwnerId,
Slot = item.Slot,
EquippedSlot = item.EquippedSlot,
UpdatedUtc = item.UpdatedUtc
};
}

View File

@ -0,0 +1,22 @@
namespace InventoryApi.Models;
public enum InventoryMutationStatus
{
Ok,
ItemNotFound,
Invalid,
Conflict
}
public class InventoryMutationResult
{
public InventoryMutationStatus Status { get; init; } = InventoryMutationStatus.Ok;
public string OwnerType { get; init; } = string.Empty;
public string OwnerId { get; init; } = string.Empty;
public List<InventoryItem> Items { get; init; } = [];
public static implicit operator InventoryMutationStatus(InventoryMutationResult result) => result.Status;
}

View File

@ -0,0 +1,10 @@
namespace InventoryApi.Models;
public class InventoryOwnerResponse
{
public string OwnerType { get; set; } = string.Empty;
public string OwnerId { get; set; } = string.Empty;
public List<InventoryItemResponse> Items { get; set; } = [];
}

View File

@ -0,0 +1,10 @@
namespace InventoryApi.Models;
public class MoveInventoryItemRequest
{
public string ItemId { get; set; } = string.Empty;
public int ToSlot { get; set; }
public int? Quantity { get; set; }
}

View File

@ -0,0 +1,16 @@
namespace InventoryApi.Models;
public class OwnerAccessResult
{
public bool IsSupported { get; init; }
public bool Exists { get; init; }
public bool IsAuthorized { get; init; }
public string OwnerType { get; init; } = string.Empty;
public string OwnerId { get; init; } = string.Empty;
public string? OwnerUserId { get; init; }
}

View File

@ -0,0 +1,18 @@
namespace InventoryApi.Models;
public class TransferInventoryItemRequest
{
public string ItemId { get; set; } = string.Empty;
public string FromOwnerType { get; set; } = string.Empty;
public string FromOwnerId { get; set; } = string.Empty;
public string ToOwnerType { get; set; } = string.Empty;
public string ToOwnerId { get; set; } = string.Empty;
public int? ToSlot { get; set; }
public int? Quantity { get; set; }
}

View File

@ -0,0 +1,18 @@
namespace InventoryApi.Models;
public class TransferInventoryResponse
{
public string MovedItemId { get; set; } = string.Empty;
public string FromOwnerType { get; set; } = string.Empty;
public string FromOwnerId { get; set; } = string.Empty;
public string ToOwnerType { get; set; } = string.Empty;
public string ToOwnerId { get; set; } = string.Empty;
public List<InventoryItemResponse> FromItems { get; set; } = [];
public List<InventoryItemResponse> ToItems { get; set; } = [];
}

View File

@ -0,0 +1,8 @@
namespace InventoryApi.Models;
public class UnequipInventoryItemRequest
{
public string OwnerId { get; set; } = string.Empty;
public int? PreferredSlot { get; set; }
}

View File

@ -0,0 +1,106 @@
using InventoryApi.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton<InventoryStore>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Inventory API", Version = "v1" });
c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "Paste your access token here (no 'Bearer ' prefix needed)."
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{ Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
},
Array.Empty<string>()
}
});
});
var cfg = builder.Configuration;
var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing");
var issuer = cfg["Jwt:Issuer"] ?? "promiscuity";
var aud = cfg["Jwt:Audience"] ?? issuer;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = aud,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var feature = context.Features.Get<IExceptionHandlerFeature>();
var exception = feature?.Error;
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("GlobalException");
var traceId = context.TraceIdentifier;
if (exception is not null)
{
logger.LogError(
exception,
"Unhandled exception for {Method} {Path}. TraceId={TraceId}",
context.Request.Method,
context.Request.Path,
traceId
);
}
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new
{
type = "https://httpstatuses.com/500",
title = "Internal Server Error",
status = 500,
detail = exception?.Message ?? "An unexpected server error occurred.",
traceId
});
});
});
app.MapGet("/healthz", () => Results.Ok("ok"));
app.UseSwagger();
app.UseSwaggerUI(o =>
{
o.SwaggerEndpoint("/swagger/v1/swagger.json", "Inventory API v1");
o.RoutePrefix = "swagger";
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,14 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5184",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,116 @@
# InventoryApi
## Purpose
Owns item-instance state.
This service should answer:
- what item records currently belong to a character, location, or other container owner
- where each item currently is
- whether an item move, split, merge, equip, or consume action is valid
- which specific item instance is being traded, auctioned, or transferred
This service should not own:
- auth/token issuance
- character identity creation
- location generation
- auction bidding logic
## Ownership model
- every inventory record has an `ownerType` and `ownerId`
- `USER` can read and mutate items owned by their own characters
- `USER` can read and mutate location/container items only when gameplay rules allow it
- `SUPER` can read and mutate any inventory item
- access should be resolved through the current owner, not from the item id alone
## Initial design
Use one MongoDB document per inventory item record.
Practical interpretation:
- non-stackable items: one document per item instance
- stackable items: one document per stack
Reasons:
- every item or stack gets a stable UUID, which is useful for auctions, trade, mail, and auditing
- ownership transfer is explicit and cheap: update `ownerType`, `ownerId`, and slot fields
- future item metadata like durability, rarity rolls, or provenance can live on the item document
- auction listings can point to specific item ids instead of vague stack descriptions
Tradeoffs:
- inventory reads are queries over many documents instead of one aggregate read
- stack merging and slot enforcement need careful indexes and mutation logic
- transfers should use transactions when they touch multiple item documents
## Suggested endpoints
- `GET /api/inventory/by-owner/{ownerType}/{ownerId}`
Return all item records currently owned by that container owner.
- `POST /api/inventory/by-owner/{ownerType}/{ownerId}/grant`
Create a new item record or add quantity into an existing compatible stack.
- `POST /api/inventory/by-owner/{ownerType}/{ownerId}/move`
Move an item or stack within the same owner inventory.
- `POST /api/inventory/transfer`
Move quantity from one owner inventory to another.
- `POST /api/inventory/items/{itemId}/consume`
Consume quantity from a specific item record.
- `POST /api/inventory/items/{itemId}/equip`
Equip a specific item into a character equipment slot.
- `POST /api/inventory/items/{itemId}/unequip`
Return an equipped item to a character inventory slot.
Notes:
- `equip` and `unequip` only make sense for character-owned items
- `transfer` is the core world interaction primitive for looting, dropping, trading, chest interaction, and auction handoff
- a future AuctionApi can reserve or re-own specific item ids without redesigning InventoryApi
## Item identity
Every inventory record should have a stable UUID string such as:
- `a8d4218b-5e20-4e47-8b5f-0f0f0b9d7e10`
Each record also carries an `itemKey` such as:
- `wood`
- `stone`
- `pistol`
- `small_health_potion`
Recommended distinction:
- `id`: unique item-record identifier used for ownership changes, auctions, and references
- `itemKey`: item definition identifier used to decide stackability and gameplay behavior
## Recommended stored shape
Each item document should include:
- `id`
- `itemKey`
- `quantity`
- `ownerType`
- `ownerId`
- `slot`
- `equippedSlot`
- `ownerUserId` when applicable
- `createdUtc`
- `updatedUtc`
Optional future fields:
- `durability`
- `rarity`
- `instanceData`
- `listingId`
- `reservedUntilUtc`
## MVP rules
- an item record must belong to exactly one owner at a time
- stackable items may share `itemKey` but should still be represented by one stack record per occupied slot
- non-stackable items must always have `quantity = 1`
- equipped items should set `equippedSlot` and clear `slot`
- unequipped bag items should set `slot` and clear `equippedSlot`
- slot occupancy must be unique per `(ownerType, ownerId, slot)`
- all mutating endpoints should be idempotent where practical
## Client shape
The Godot client should fetch all items for the currently relevant owner and group them into a bag view locally.
Good pattern:
- login/select character
- `GET /api/inventory/by-owner/character/{characterId}`
- when opening a stash or world container: `GET /api/inventory/by-owner/location/{locationId}`
- cache the returned item records locally
- call transfer/equip/consume endpoints using specific `itemId` values
- replace local state with the server response after each mutation

View File

@ -0,0 +1,509 @@
using InventoryApi.Models;
using MongoDB.Bson;
using MongoDB.Driver;
namespace InventoryApi.Services;
public class InventoryStore
{
private const string CharacterOwnerType = "character";
private const string LocationOwnerType = "location";
private const string ItemIdIndexName = "item_id_unique";
private const string OwnerIndexName = "owner_type_1_owner_id_1";
private const string SlotIndexName = "owner_type_1_owner_id_1_slot_1";
private const string EquippedSlotIndexName = "owner_type_1_owner_id_1_equipped_slot_1";
private readonly IMongoCollection<InventoryItem> _items;
private readonly IMongoCollection<CharacterOwnerDocument> _characters;
private readonly IMongoCollection<LocationOwnerDocument> _locations;
private readonly IMongoClient _client;
private readonly string _dbName;
private readonly HashSet<string> _stackableItemKeys = ["wood", "stone", "small_health_potion"];
public InventoryStore(IConfiguration cfg)
{
var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
_dbName = cfg["MongoDB:DatabaseName"] ?? "promiscuity";
_client = new MongoClient(cs);
var db = _client.GetDatabase(_dbName);
_items = db.GetCollection<InventoryItem>("InventoryItems");
_characters = db.GetCollection<CharacterOwnerDocument>("Characters");
_locations = db.GetCollection<LocationOwnerDocument>("Locations");
EnsureIndexes();
}
public async Task<OwnerAccessResult> ResolveOwnerAsync(string ownerType, string ownerId, string userId, bool allowAnyOwner)
{
var normalizedOwnerType = NormalizeOwnerType(ownerType);
if (normalizedOwnerType is null)
return new OwnerAccessResult { IsSupported = false };
if (normalizedOwnerType == CharacterOwnerType)
{
var character = await _characters.Find(c => c.Id == ownerId).FirstOrDefaultAsync();
if (character is null)
{
return new OwnerAccessResult
{
IsSupported = true,
Exists = false,
OwnerType = normalizedOwnerType,
OwnerId = ownerId
};
}
var authorized = allowAnyOwner || character.OwnerUserId == userId;
return new OwnerAccessResult
{
IsSupported = true,
Exists = true,
IsAuthorized = authorized,
OwnerType = normalizedOwnerType,
OwnerId = ownerId,
OwnerUserId = character.OwnerUserId
};
}
var location = await _locations.Find(l => l.Id == ownerId).FirstOrDefaultAsync();
return new OwnerAccessResult
{
IsSupported = true,
Exists = location is not null,
IsAuthorized = location is not null,
OwnerType = normalizedOwnerType,
OwnerId = ownerId,
OwnerUserId = null
};
}
public Task<List<InventoryItem>> GetByOwnerAsync(string ownerType, string ownerId) =>
_items.Find(i => i.OwnerType == ownerType && i.OwnerId == ownerId)
.SortBy(i => i.EquippedSlot)
.ThenBy(i => i.Slot)
.ThenBy(i => i.ItemKey)
.ToListAsync();
public async Task<List<InventoryItem>> GrantAsync(OwnerAccessResult owner, GrantInventoryItemRequest req)
{
var normalizedKey = req.ItemKey.Trim();
if (IsStackable(normalizedKey))
{
var targetSlot = req.PreferredSlot ?? await FindFirstOpenSlotAsync(owner.OwnerType, owner.OwnerId);
var existing = await FindStackAsync(owner.OwnerType, owner.OwnerId, normalizedKey, targetSlot);
if (existing is not null)
{
existing.Quantity += req.Quantity;
existing.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(existing);
return await GetByOwnerAsync(owner.OwnerType, owner.OwnerId);
}
await InsertItemAsync(new InventoryItem
{
ItemKey = normalizedKey,
Quantity = req.Quantity,
OwnerType = owner.OwnerType,
OwnerId = owner.OwnerId,
OwnerUserId = owner.OwnerUserId,
Slot = targetSlot
});
return await GetByOwnerAsync(owner.OwnerType, owner.OwnerId);
}
var nextPreferredSlot = req.PreferredSlot;
for (var index = 0; index < req.Quantity; index += 1)
{
var slot = nextPreferredSlot ?? await FindFirstOpenSlotAsync(owner.OwnerType, owner.OwnerId);
await InsertItemAsync(new InventoryItem
{
ItemKey = normalizedKey,
Quantity = 1,
OwnerType = owner.OwnerType,
OwnerId = owner.OwnerId,
OwnerUserId = owner.OwnerUserId,
Slot = slot
});
nextPreferredSlot = null;
}
return await GetByOwnerAsync(owner.OwnerType, owner.OwnerId);
}
public async Task<InventoryMutationResult> MoveAsync(OwnerAccessResult owner, MoveInventoryItemRequest req)
{
var item = await _items.Find(i => i.Id == req.ItemId).FirstOrDefaultAsync();
if (item is null)
return new InventoryMutationResult { Status = InventoryMutationStatus.ItemNotFound };
if (item.OwnerType != owner.OwnerType || item.OwnerId != owner.OwnerId || item.EquippedSlot is not null)
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
var quantity = req.Quantity ?? item.Quantity;
if (quantity <= 0 || quantity > item.Quantity)
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
var existing = await FindItemBySlotAsync(owner.OwnerType, owner.OwnerId, req.ToSlot);
if (existing is not null && existing.Id != item.Id)
{
if (!CanMerge(item, existing))
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
existing.Quantity += quantity;
existing.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(existing);
if (quantity == item.Quantity)
await DeleteItemAsync(item.Id);
else
{
item.Quantity -= quantity;
item.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(item);
}
}
else if (quantity == item.Quantity)
{
item.Slot = req.ToSlot;
item.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(item);
}
else
{
item.Quantity -= quantity;
item.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(item);
await InsertItemAsync(new InventoryItem
{
ItemKey = item.ItemKey,
Quantity = quantity,
OwnerType = item.OwnerType,
OwnerId = item.OwnerId,
OwnerUserId = item.OwnerUserId,
Slot = req.ToSlot
});
}
return new InventoryMutationResult
{
Status = InventoryMutationStatus.Ok,
OwnerType = owner.OwnerType,
OwnerId = owner.OwnerId,
Items = await GetByOwnerAsync(owner.OwnerType, owner.OwnerId)
};
}
public async Task<InventoryMutationResult> TransferAsync(OwnerAccessResult fromOwner, OwnerAccessResult toOwner, TransferInventoryItemRequest req)
{
using var session = await _client.StartSessionAsync();
session.StartTransaction();
try
{
var item = await _items.Find(session, i => i.Id == req.ItemId).FirstOrDefaultAsync();
if (item is null)
{
await session.AbortTransactionAsync();
return new InventoryMutationResult { Status = InventoryMutationStatus.ItemNotFound };
}
if (item.OwnerType != fromOwner.OwnerType || item.OwnerId != fromOwner.OwnerId || item.EquippedSlot is not null)
{
await session.AbortTransactionAsync();
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
}
var quantity = req.Quantity ?? item.Quantity;
if (quantity <= 0 || quantity > item.Quantity)
{
await session.AbortTransactionAsync();
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
}
var toSlot = req.ToSlot ?? await FindFirstOpenSlotAsync(toOwner.OwnerType, toOwner.OwnerId, session);
var target = await FindItemBySlotAsync(toOwner.OwnerType, toOwner.OwnerId, toSlot, session);
if (target is not null && !CanMerge(item, target))
{
await session.AbortTransactionAsync();
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
}
if (target is not null)
{
target.Quantity += quantity;
target.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(target, session);
if (quantity == item.Quantity)
await DeleteItemAsync(item.Id, session);
else
{
item.Quantity -= quantity;
item.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(item, session);
}
}
else if (quantity == item.Quantity)
{
item.OwnerType = toOwner.OwnerType;
item.OwnerId = toOwner.OwnerId;
item.OwnerUserId = toOwner.OwnerUserId;
item.Slot = toSlot;
item.EquippedSlot = null;
item.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(item, session);
}
else
{
item.Quantity -= quantity;
item.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(item, session);
await InsertItemAsync(new InventoryItem
{
ItemKey = item.ItemKey,
Quantity = quantity,
OwnerType = toOwner.OwnerType,
OwnerId = toOwner.OwnerId,
OwnerUserId = toOwner.OwnerUserId,
Slot = toSlot
}, session);
}
await session.CommitTransactionAsync();
var fromItems = await GetByOwnerAsync(fromOwner.OwnerType, fromOwner.OwnerId);
var toItems = await GetByOwnerAsync(toOwner.OwnerType, toOwner.OwnerId);
return new InventoryMutationResult
{
Status = InventoryMutationStatus.Ok,
Items = fromItems.Concat(toItems).ToList()
};
}
catch
{
await session.AbortTransactionAsync();
throw;
}
}
public async Task<InventoryMutationResult> ConsumeAsync(string itemId, int quantity, string userId, bool allowAnyOwner)
{
var item = await _items.Find(i => i.Id == itemId).FirstOrDefaultAsync();
if (item is null)
return new InventoryMutationResult { Status = InventoryMutationStatus.ItemNotFound };
var access = await ResolveOwnerAsync(item.OwnerType, item.OwnerId, userId, allowAnyOwner);
if (!access.Exists || !access.IsAuthorized)
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
if (quantity > item.Quantity)
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
if (quantity == item.Quantity)
await DeleteItemAsync(item.Id);
else
{
item.Quantity -= quantity;
item.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(item);
}
return new InventoryMutationResult
{
Status = InventoryMutationStatus.Ok,
OwnerType = item.OwnerType,
OwnerId = item.OwnerId,
Items = await GetByOwnerAsync(item.OwnerType, item.OwnerId)
};
}
public async Task<InventoryMutationResult> EquipAsync(string itemId, string ownerId, string equipmentSlot, string userId, bool allowAnyOwner)
{
var owner = await ResolveOwnerAsync(CharacterOwnerType, ownerId, userId, allowAnyOwner);
if (!owner.Exists || !owner.IsAuthorized)
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
var item = await _items.Find(i => i.Id == itemId).FirstOrDefaultAsync();
if (item is null)
return new InventoryMutationResult { Status = InventoryMutationStatus.ItemNotFound };
if (item.OwnerType != CharacterOwnerType || item.OwnerId != ownerId || item.Slot is null)
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
var equipped = await _items.Find(i =>
i.OwnerType == CharacterOwnerType &&
i.OwnerId == ownerId &&
i.EquippedSlot == equipmentSlot).FirstOrDefaultAsync();
if (equipped is not null && equipped.Id != item.Id)
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
item.Slot = null;
item.EquippedSlot = equipmentSlot.Trim();
item.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(item);
return new InventoryMutationResult
{
Status = InventoryMutationStatus.Ok,
OwnerType = item.OwnerType,
OwnerId = item.OwnerId,
Items = await GetByOwnerAsync(item.OwnerType, item.OwnerId)
};
}
public async Task<InventoryMutationResult> UnequipAsync(string itemId, string ownerId, int? preferredSlot, string userId, bool allowAnyOwner)
{
var owner = await ResolveOwnerAsync(CharacterOwnerType, ownerId, userId, allowAnyOwner);
if (!owner.Exists || !owner.IsAuthorized)
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
var item = await _items.Find(i => i.Id == itemId).FirstOrDefaultAsync();
if (item is null)
return new InventoryMutationResult { Status = InventoryMutationStatus.ItemNotFound };
if (item.OwnerType != CharacterOwnerType || item.OwnerId != ownerId || string.IsNullOrWhiteSpace(item.EquippedSlot))
return new InventoryMutationResult { Status = InventoryMutationStatus.Invalid };
var slot = preferredSlot ?? await FindFirstOpenSlotAsync(item.OwnerType, item.OwnerId);
var existing = await FindItemBySlotAsync(item.OwnerType, item.OwnerId, slot);
if (existing is not null && existing.Id != item.Id)
return new InventoryMutationResult { Status = InventoryMutationStatus.Conflict };
item.EquippedSlot = null;
item.Slot = slot;
item.UpdatedUtc = DateTime.UtcNow;
await ReplaceItemAsync(item);
return new InventoryMutationResult
{
Status = InventoryMutationStatus.Ok,
OwnerType = item.OwnerType,
OwnerId = item.OwnerId,
Items = await GetByOwnerAsync(item.OwnerType, item.OwnerId)
};
}
private static string? NormalizeOwnerType(string ownerType)
{
var normalized = ownerType.Trim().ToLowerInvariant();
return normalized switch
{
CharacterOwnerType => CharacterOwnerType,
LocationOwnerType => LocationOwnerType,
_ => null
};
}
private async Task<int> FindFirstOpenSlotAsync(string ownerType, string ownerId, IClientSessionHandle? session = null)
{
var items = session is null
? await GetByOwnerAsync(ownerType, ownerId)
: await _items.Find(session, i => i.OwnerType == ownerType && i.OwnerId == ownerId).ToListAsync();
var usedSlots = items.Where(i => i.Slot.HasValue).Select(i => i.Slot!.Value).ToHashSet();
var slot = 0;
while (usedSlots.Contains(slot))
slot += 1;
return slot;
}
private Task<InventoryItem?> FindItemBySlotAsync(string ownerType, string ownerId, int slot, IClientSessionHandle? session = null)
=> session is null
? FindItemBySlotNoSessionAsync(ownerType, ownerId, slot)
: FindItemBySlotWithSessionAsync(ownerType, ownerId, slot, session);
private Task<InventoryItem?> FindStackAsync(string ownerType, string ownerId, string itemKey, int slot, IClientSessionHandle? session = null)
=> session is null
? FindStackNoSessionAsync(ownerType, ownerId, itemKey, slot)
: FindStackWithSessionAsync(ownerType, ownerId, itemKey, slot, session);
private async Task InsertItemAsync(InventoryItem item, IClientSessionHandle? session = null)
{
item.ItemKey = item.ItemKey.Trim();
item.OwnerType = item.OwnerType.Trim().ToLowerInvariant();
item.CreatedUtc = DateTime.UtcNow;
item.UpdatedUtc = item.CreatedUtc;
if (session is null)
await _items.InsertOneAsync(item);
else
await _items.InsertOneAsync(session, item);
}
private async Task ReplaceItemAsync(InventoryItem item, IClientSessionHandle? session = null)
{
item.UpdatedUtc = DateTime.UtcNow;
if (session is null)
await _items.ReplaceOneAsync(i => i.Id == item.Id, item);
else
await _items.ReplaceOneAsync(session, i => i.Id == item.Id, item);
}
private Task DeleteItemAsync(string itemId, IClientSessionHandle? session = null)
{
return session is null
? _items.DeleteOneAsync(i => i.Id == itemId)
: _items.DeleteOneAsync(session, i => i.Id == itemId);
}
private bool IsStackable(string itemKey) => _stackableItemKeys.Contains(itemKey.Trim().ToLowerInvariant());
private bool CanMerge(InventoryItem source, InventoryItem target) =>
source.ItemKey == target.ItemKey &&
source.EquippedSlot is null &&
target.EquippedSlot is null &&
IsStackable(source.ItemKey);
private void EnsureIndexes()
{
_items.Indexes.CreateOne(new CreateIndexModel<InventoryItem>(
Builders<InventoryItem>.IndexKeys.Ascending(i => i.Id),
new CreateIndexOptions { Unique = true, Name = ItemIdIndexName }));
_items.Indexes.CreateOne(new CreateIndexModel<InventoryItem>(
Builders<InventoryItem>.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId),
new CreateIndexOptions { Name = OwnerIndexName }));
_items.Indexes.CreateOne(new CreateIndexModel<InventoryItem>(
Builders<InventoryItem>.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId).Ascending(i => i.Slot),
new CreateIndexOptions<InventoryItem>
{
Unique = true,
Name = SlotIndexName,
PartialFilterExpression = Builders<InventoryItem>.Filter.Ne(i => i.Slot, null)
}));
_items.Indexes.CreateOne(new CreateIndexModel<InventoryItem>(
Builders<InventoryItem>.IndexKeys.Ascending(i => i.OwnerType).Ascending(i => i.OwnerId).Ascending(i => i.EquippedSlot),
new CreateIndexOptions<InventoryItem>
{
Unique = true,
Name = EquippedSlotIndexName,
PartialFilterExpression = Builders<InventoryItem>.Filter.Ne(i => i.EquippedSlot, null)
}));
}
private async Task<InventoryItem?> FindItemBySlotNoSessionAsync(string ownerType, string ownerId, int slot) =>
await _items.Find(i => i.OwnerType == ownerType && i.OwnerId == ownerId && i.Slot == slot).FirstOrDefaultAsync();
private async Task<InventoryItem?> FindItemBySlotWithSessionAsync(string ownerType, string ownerId, int slot, IClientSessionHandle session) =>
await _items.Find(session, i => i.OwnerType == ownerType && i.OwnerId == ownerId && i.Slot == slot).FirstOrDefaultAsync();
private async Task<InventoryItem?> FindStackNoSessionAsync(string ownerType, string ownerId, string itemKey, int slot) =>
await _items.Find(i => i.OwnerType == ownerType && i.OwnerId == ownerId && i.Slot == slot && i.ItemKey == itemKey && i.EquippedSlot == null).FirstOrDefaultAsync();
private async Task<InventoryItem?> FindStackWithSessionAsync(string ownerType, string ownerId, string itemKey, int slot, IClientSessionHandle session) =>
await _items.Find(session, i => i.OwnerType == ownerType && i.OwnerId == ownerId && i.Slot == slot && i.ItemKey == itemKey && i.EquippedSlot == null).FirstOrDefaultAsync();
private class CharacterOwnerDocument
{
[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;
}
private class LocationOwnerDocument
{
[MongoDB.Bson.Serialization.Attributes.BsonId]
[MongoDB.Bson.Serialization.Attributes.BsonRepresentation(MongoDB.Bson.BsonType.ObjectId)]
public string? Id { get; set; }
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,7 @@
{
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5003" } } },
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
"Logging": { "LogLevel": { "Default": "Information" } },
"AllowedHosts": "*"
}

View File

@ -0,0 +1,28 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: promiscuity-inventory
labels:
app: promiscuity-inventory
spec:
replicas: 2
selector:
matchLabels:
app: promiscuity-inventory
template:
metadata:
labels:
app: promiscuity-inventory
spec:
containers:
- name: promiscuity-inventory
image: promiscuity-inventory:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5003
readinessProbe:
httpGet:
path: /healthz
port: 5003
initialDelaySeconds: 5
periodSeconds: 10

View File

@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: promiscuity-inventory
labels:
app: promiscuity-inventory
spec:
selector:
app: promiscuity-inventory
type: NodePort
ports:
- name: http
port: 80
targetPort: 5003
nodePort: 30083

View File

@ -3,10 +3,12 @@
## Document shapes
- AuthApi: `AuthApi/DOCUMENTS.md` (auth request payloads and user document shape)
- CharacterApi: `CharacterApi/DOCUMENTS.md` (character create payload and stored document)
- InventoryApi: `InventoryApi/DOCUMENTS.md` (inventory mutation payloads and stored document)
- LocationsApi: `LocationsApi/DOCUMENTS.md` (location create/update payloads and stored document)
## Service READMEs
- AuthApi: `AuthApi/README.md`
- CharacterApi: `CharacterApi/README.md`
- InventoryApi: `InventoryApi/README.md`
- LocationsApi: `LocationsApi/README.md`

View File

@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CharacterApi", "CharacterAp
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocationsApi", "LocationsApi\LocationsApi.csproj", "{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InventoryApi", "InventoryApi\InventoryApi.csproj", "{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -26,6 +28,10 @@ Global
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|Any CPU.Build.0 = Release|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE