V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -0,0 +1,79 @@
// Generated from contracts/api/kbx.api.json. Do not edit.
namespace Shared.Contracts.Generated;
public sealed record KbxApiOperationContract(
string Id, string Method, string Path, string? Permission, string Kind, string Idempotency, IReadOnlyList<int> SuccessStatuses);
public static class KbxApiCatalog
{
public const string SourceSha256 = "8a8008b0eca039ef22b703b142dd7fa2efeaf5b14784ba9f3a954f656a1a0c8f";
public static readonly IReadOnlyList<KbxApiOperationContract> All = new KbxApiOperationContract[]
{
new("common.ai.ask", "POST", "/api/common/ai/ask", "common.ai.use", "command", "supported", new[] { 200 }),
new("common.experiments.assignments", "GET", "/api/kbx/experiments/assignments", "common.experiment.evaluate", "query", "none", new[] { 200 }),
new("common.experiments.overview", "GET", "/api/kbx/experiments", "common.experiment.read", "query", "none", new[] { 200 }),
new("common.experiments.rollback", "POST", "/api/kbx/experiments/{experimentId}/rollback", "common.experiment.manage", "command", "supported", new[] { 200 }),
new("common.experiments.rollout", "POST", "/api/kbx/experiments/{experimentId}/rollout", "common.experiment.manage", "command", "supported", new[] { 200 }),
new("common.externalData.refresh", "POST", "/api/kbx/external-data/{datasetId}/refresh", "common.external-data.refresh", "command", "supported", new[] { 202 }),
new("common.externalData.status", "GET", "/api/kbx/external-data/status", "common.external-data.read", "query", "none", new[] { 200 }),
new("common.imports.commit", "POST", "/api/imports/sessions/{sessionId:guid}/commit", "imports.execute", "command", "supported", new[] { 200 }),
new("common.imports.createSession", "POST", "/api/imports/sessions", "imports.execute", "upload", "supported", new[] { 200 }),
new("common.imports.errorWorkbook", "GET", "/api/imports/sessions/{sessionId:guid}/errors.xlsx", "imports.execute", "query", "none", new[] { 200 }),
new("common.imports.getSession", "GET", "/api/imports/sessions/{sessionId:guid}", "imports.execute", "query", "none", new[] { 200 }),
new("common.imports.saveMapping", "PUT", "/api/imports/sessions/{sessionId:guid}/mapping", "imports.execute", "command", "supported", new[] { 200 }),
new("common.imports.saveNamedMapping", "POST", "/api/imports/sessions/{sessionId:guid}/saved-mappings", "imports.execute", "command", "supported", new[] { 204 }),
new("common.imports.template", "GET", "/api/imports/{importType}/template", "imports.execute", "query", "none", new[] { 200 }),
new("common.imports.validate", "POST", "/api/imports/sessions/{sessionId:guid}/validate", "imports.execute", "command", "supported", new[] { 200 }),
new("common.integrations.getAttempt", "GET", "/api/integrations/attempts/{attemptId:guid}", "common.integration.read", "query", "none", new[] { 200 }),
new("common.integrations.retryAttempt", "POST", "/api/integrations/attempts/{attemptId:guid}/retry", "common.integration.retry", "command", "required", new[] { 200 }),
new("common.operations.claim", "POST", "/api/operations/work-items/claim", "common.operations.claim", "command", "supported", new[] { 200 }),
new("common.operations.resolve", "POST", "/api/operations/work-items/resolve", "common.operations.resolve", "command", "supported", new[] { 200 }),
new("common.operations.retry", "POST", "/api/operations/work-items/{id:guid}/retry", "common.operations.retry", "command", "supported", new[] { 200 }),
new("common.operations.search", "GET", "/api/operations/work-items", "common.operations.read", "query", "none", new[] { 200 }),
new("common.reconcile.createExceptions", "POST", "/api/reconcile/items/create-exceptions", "common.operations.create", "command", "supported", new[] { 200 }),
new("common.reconcile.search", "GET", "/api/reconcile/items", "common.reconcile.read", "query", "none", new[] { 200 }),
new("common.runtime.notice", "GET", "/api/kbx/runtime/notice", "common.runtime.read", "query", "none", new[] { 200 }),
new("common.runtime.notificationRead", "POST", "/api/kbx/runtime/notifications/{Id}/read", "common.runtime.read", "command", "supported", new[] { 204 }),
new("common.runtime.notifications", "GET", "/api/kbx/runtime/notifications", "common.runtime.read", "query", "none", new[] { 200 }),
new("common.runtime.operations", "GET", "/api/kbx/runtime/operations", "common.runtime.read", "query", "none", new[] { 200 }),
new("common.suggestions.submit", "POST", "/api/common/suggestions", "common.suggestion.create", "command", "supported", new[] { 200 }),
new("common.uxTelemetry.ingest", "POST", "/api/kbx/ux/events", "common.telemetry.write", "command", "none", new[] { 204 }),
new("common.uxTelemetry.metrics", "GET", "/api/kbx/ux/metrics", "common.ux.read", "query", "none", new[] { 200 }),
new("erp.inventory.history", "GET", "/api/erp/inventory/{itemId}/history", "erp.inventory.read", "query", "none", new[] { 200 }),
new("erp.inventory.locations", "GET", "/api/erp/inventory/{itemId}/locations", "erp.inventory.read", "query", "none", new[] { 200 }),
new("erp.inventory.search", "GET", "/api/erp/inventory", "erp.inventory.read", "query", "none", new[] { 200 }),
new("erp.itemPrices.bulkSave", "POST", "/api/erp/item-prices/bulk", "erp.item.price.write", "command", "required", new[] { 200 }),
new("erp.items.get", "GET", "/api/erp/items/{id}", "erp.item.read", "query", "none", new[] { 200 }),
new("erp.items.search", "GET", "/api/erp/items", "erp.item.read", "query", "none", new[] { 200 }),
new("lookup.customers.resolveByCode", "GET", "/api/lookups/customers/by-code/{code}", "oms.order.read", "query", "none", new[] { 200 }),
new("lookup.customers.resolveById", "GET", "/api/lookups/customers/{id}", "oms.order.read", "query", "none", new[] { 200 }),
new("lookup.customers.search", "GET", "/api/lookups/customers", "oms.order.read", "query", "none", new[] { 200 }),
new("lookup.items.resolveByCode", "GET", "/api/lookups/items/by-code/{code}", "erp.item.read", "query", "none", new[] { 200 }),
new("lookup.items.resolveById", "GET", "/api/lookups/items/{id}", "erp.item.read", "query", "none", new[] { 200 }),
new("lookup.items.search", "GET", "/api/lookups/items", "erp.item.read", "query", "none", new[] { 200 }),
new("lookup.warehouses.resolveByCode", "GET", "/api/lookups/warehouses/by-code/{code}", "oms.order.read", "query", "none", new[] { 200 }),
new("lookup.warehouses.resolveById", "GET", "/api/lookups/warehouses/{id}", "oms.order.read", "query", "none", new[] { 200 }),
new("lookup.warehouses.search", "GET", "/api/lookups/warehouses", "oms.order.read", "query", "none", new[] { 200 }),
new("oms.claims.approve", "POST", "/api/oms/claims/{id:guid}/approve", "oms.claim.approve", "command", "supported", new[] { 200 }),
new("oms.claims.complete", "POST", "/api/oms/claims/{id:guid}/complete", "oms.claim.process", "command", "supported", new[] { 200 }),
new("oms.claims.hold", "POST", "/api/oms/claims/{id:guid}/hold", "oms.claim.hold", "command", "supported", new[] { 200 }),
new("oms.claims.search", "GET", "/api/oms/claims", "oms.claim.read", "query", "none", new[] { 200 }),
new("oms.claims.start", "POST", "/api/oms/claims/{id:guid}/start", "oms.claim.process", "command", "supported", new[] { 200 }),
new("oms.orders.register", "POST", "/api/oms/orders/register", "oms.order.create", "command", "supported", new[] { 200 }),
new("oms.orders.search", "GET", "/api/oms/orders", "oms.order.read", "query", "none", new[] { 200 }),
new("oms.orders.ship", "POST", "/api/oms/orders/ship", "oms.order.ship", "command", "required", new[] { 200 }),
new("wms.picking.getTask", "GET", "/api/wms/picking/tasks/{taskId:guid}", "wms.picking.execute", "query", "none", new[] { 200 }),
new("wms.picking.reportException", "POST", "/api/wms/picking/tasks/{taskId:guid}/exceptions", "wms.picking.execute", "command", "required", new[] { 200 }),
new("wms.picking.scan", "POST", "/api/wms/picking/tasks/{taskId:guid}/scan", "wms.picking.execute", "command", "required", new[] { 200, 422 }),
new("wms.picking.setQuantity", "POST", "/api/wms/picking/tasks/{taskId:guid}/quantity", "wms.picking.execute", "command", "required", new[] { 200 }),
new("wms.picking.start", "POST", "/api/wms/picking/tasks/{taskId:guid}/start", "wms.picking.execute", "command", "supported", new[] { 200 }),
new("erp.items.create", "POST", "/api/erp/items", "erp.item.create", "command", "none", new[] { 200 }),
new("erp.items.update", "PUT", "/api/erp/items/{id}", "erp.item.write", "command", "none", new[] { 200 }),
new("erp.items.deactivate", "POST", "/api/erp/items/{id}/deactivate", "erp.item.write", "command", "none", new[] { 200 }),
new("erp.items.audit", "GET", "/api/erp/items/{id}/audit", "erp.item.read", "query", "none", new[] { 200 }),
new("oms.orders.get", "GET", "/api/oms/orders/{id}", "oms.order.read", "query", "none", new[] { 200 }),
new("oms.orders.confirm", "POST", "/api/oms/orders/{id}/confirm", "oms.order.confirm", "command", "required", new[] { 200 }),
new("oms.orders.audit", "GET", "/api/oms/orders/{id}/audit", "oms.order.read", "query", "none", new[] { 200 }),
new("oms.orders.update", "PUT", "/api/oms/orders/{id}", "oms.order.write", "command", "supported", new[] { 200 })
};
}
@@ -0,0 +1,131 @@
// <auto-generated />
// Source: contracts/fields/kbx.fields.json
// Source SHA256: f76e073cd8052e9fddb25cc73322686c889822a22fab712ff1a25bef29b32e3c
namespace Kbx.Contracts.Generated;
public sealed record KbxFieldMetadata(
string Key, string Label, string DataType, IReadOnlyList<string> Aliases,
bool Required, int? MaxLength, int? Precision, int? Scale, string? LookupEntity,
bool Readonly, bool Importable, bool Exportable, bool Sensitive, string? Masking);
public static class KbxFieldKeys
{
public const string Address1 = "address1";
public const string Address2 = "address2";
public const string AllocatedQty = "allocatedQty";
public const string Amount = "amount";
public const string AvailableQty = "availableQty";
public const string Barcode = "barcode";
public const string ChannelId = "channelId";
public const string ChannelName = "channelName";
public const string CreatedAt = "createdAt";
public const string CreatedBy = "createdBy";
public const string CustomerCode = "customerCode";
public const string CustomerId = "customerId";
public const string CustomerName = "customerName";
public const string DamagedQty = "damagedQty";
public const string EffectiveDate = "effectiveDate";
public const string ExceptionCount = "exceptionCount";
public const string ExpiryDate = "expiryDate";
public const string HoldQty = "holdQty";
public const string ItemCode = "itemCode";
public const string ItemId = "itemId";
public const string ItemName = "itemName";
public const string ItemSummary = "itemSummary";
public const string LocationCode = "locationCode";
public const string LocationId = "locationId";
public const string LotNo = "lotNo";
public const string OccurredAt = "occurredAt";
public const string OnHandQty = "onHandQty";
public const string OrderDate = "orderDate";
public const string OrderedAt = "orderedAt";
public const string OrderId = "orderId";
public const string OrderNo = "orderNo";
public const string OrderQty = "orderQty";
public const string OwnerName = "ownerName";
public const string Phone = "phone";
public const string PickedQty = "pickedQty";
public const string PostalCode = "postalCode";
public const string Quantity = "quantity";
public const string ReceiverName = "receiverName";
public const string ReferenceNo = "referenceNo";
public const string Remark = "remark";
public const string ShippedQty = "shippedQty";
public const string Specification = "specification";
public const string Status = "status";
public const string TotalQty = "totalQty";
public const string UnitPrice = "unitPrice";
public const string UpdatedAt = "updatedAt";
public const string UpdatedBy = "updatedBy";
public const string Version = "version";
public const string WarehouseCode = "warehouseCode";
public const string WarehouseId = "warehouseId";
public const string WarehouseName = "warehouseName";
}
public static class KbxFieldCatalog
{
public const string Version = "1.1.0";
private static readonly IReadOnlyDictionary<string, KbxFieldMetadata> Items =
new Dictionary<string, KbxFieldMetadata>(StringComparer.Ordinal)
{
["address1"] = new("address1", "주소", "text", new[] { "기본주소", "배송주소", "Address1" }, true, 500, null, null, null, false, true, true, true, "address"),
["address2"] = new("address2", "상세주소", "text", new[] { "주소2", "Address2" }, false, 500, null, null, null, false, true, true, true, "address"),
["allocatedQty"] = new("allocatedQty", "할당수량", "quantity", new[] { "AllocatedQty" }, false, null, 18, 4, null, true, false, true, false, null),
["amount"] = new("amount", "금액", "money", new[] { "Amount", "주문금액" }, false, null, 18, 2, null, true, false, true, false, null),
["availableQty"] = new("availableQty", "가용재고", "quantity", new[] { "AvailableQty", "출고가능수량" }, false, null, 18, 4, null, true, false, true, false, null),
["barcode"] = new("barcode", "바코드", "code", new[] { "Barcode", "EAN", "JAN" }, false, 100, null, null, "item", false, true, true, false, null),
["channelId"] = new("channelId", "판매채널ID", "lookup", new[] { "ChannelId" }, false, null, null, null, "salesChannel", true, false, false, false, null),
["channelName"] = new("channelName", "판매채널", "text", new[] { "ChannelName", "채널" }, false, null, null, null, null, true, false, true, false, null),
["createdAt"] = new("createdAt", "등록일시", "datetime", new[] { "CreatedAt" }, false, null, null, null, null, true, false, true, false, null),
["createdBy"] = new("createdBy", "등록자", "text", new[] { "CreatedBy" }, false, null, null, null, null, true, false, true, false, null),
["customerCode"] = new("customerCode", "거래처코드", "code", new[] { "거래처", "업체코드", "CustomerCode" }, true, 50, null, null, "customer", false, true, true, false, null),
["customerId"] = new("customerId", "거래처ID", "lookup", new[] { "CustomerId" }, false, null, null, null, "customer", true, false, false, false, null),
["customerName"] = new("customerName", "거래처명", "text", new[] { "CustomerName", "업체명" }, false, 200, null, null, null, true, false, true, false, null),
["damagedQty"] = new("damagedQty", "불량수량", "quantity", new[] { "DamagedQty" }, false, null, 18, 4, null, true, false, true, false, null),
["effectiveDate"] = new("effectiveDate", "적용일", "date", new[] { "EffectiveDate", "시작일", "적용시작일" }, true, null, null, null, null, false, true, true, false, null),
["exceptionCount"] = new("exceptionCount", "예외건수", "integer", new[] { "ExceptionCount", "오류건수" }, false, null, null, null, null, true, false, true, false, null),
["expiryDate"] = new("expiryDate", "유통기한", "date", new[] { "ExpiryDate", "ExpirationDate" }, false, null, null, null, null, false, true, true, false, null),
["holdQty"] = new("holdQty", "보류수량", "quantity", new[] { "HoldQty" }, false, null, 18, 4, null, true, false, true, false, null),
["itemCode"] = new("itemCode", "품목코드", "code", new[] { "상품코드", "SKU", "ItemCode" }, true, 80, null, null, "item", false, true, true, false, null),
["itemId"] = new("itemId", "품목ID", "lookup", new[] { "ItemId" }, false, null, null, null, "item", true, false, false, false, null),
["itemName"] = new("itemName", "품목명", "text", new[] { "상품명", "ItemName" }, false, 300, null, null, null, true, false, true, false, null),
["itemSummary"] = new("itemSummary", "대표상품", "text", new[] { "ItemSummary" }, false, null, null, null, null, true, false, true, false, null),
["locationCode"] = new("locationCode", "로케이션", "code", new[] { "Location", "LocationCode", "위치" }, false, 80, null, null, "location", false, true, true, false, null),
["locationId"] = new("locationId", "로케이션ID", "lookup", new[] { "LocationId" }, false, null, null, null, "location", true, false, false, false, null),
["lotNo"] = new("lotNo", "LOT", "code", new[] { "LotNo", "LOT번호" }, false, 100, null, null, null, false, true, true, false, null),
["occurredAt"] = new("occurredAt", "발생시각", "datetime", new[] { "OccurredAt" }, false, null, null, null, null, true, false, true, false, null),
["onHandQty"] = new("onHandQty", "현재고", "quantity", new[] { "OnHandQty", "장부재고" }, false, null, 18, 4, null, true, false, true, false, null),
["orderDate"] = new("orderDate", "주문일", "date", new[] { "주문일자", "OrderDate" }, true, null, null, null, null, false, true, true, false, null),
["orderedAt"] = new("orderedAt", "주문일시", "datetime", new[] { "OrderedAt", "주문시간" }, false, null, null, null, null, true, false, true, false, null),
["orderId"] = new("orderId", "주문ID", "code", new[] { "OrderId" }, false, null, null, null, null, true, false, false, false, null),
["orderNo"] = new("orderNo", "주문번호", "code", new[] { "OrderNo", "주문ID" }, true, 50, null, null, null, false, true, true, false, null),
["orderQty"] = new("orderQty", "주문수량", "quantity", new[] { "주문수량", "수량", "Qty", "OrderQty" }, true, null, 18, 4, null, false, true, true, false, null),
["ownerName"] = new("ownerName", "담당자", "text", new[] { "OwnerName", "담당" }, false, 100, null, null, null, true, false, true, false, null),
["phone"] = new("phone", "연락처", "text", new[] { "휴대폰", "전화번호", "Phone" }, true, 50, null, null, null, false, true, true, true, "phone"),
["pickedQty"] = new("pickedQty", "피킹수량", "quantity", new[] { "PickedQty" }, false, null, 18, 4, null, true, false, true, false, null),
["postalCode"] = new("postalCode", "우편번호", "text", new[] { "ZipCode", "PostalCode" }, false, 20, null, null, null, false, true, true, true, "address"),
["quantity"] = new("quantity", "수량", "quantity", new[] { "Qty" }, false, null, 18, 4, null, false, false, false, false, null),
["receiverName"] = new("receiverName", "수취인", "text", new[] { "받는분", "수령인", "ReceiverName" }, true, 100, null, null, null, false, true, true, true, "name"),
["referenceNo"] = new("referenceNo", "참조번호", "code", new[] { "ReferenceNo" }, false, null, null, null, null, true, false, true, false, null),
["remark"] = new("remark", "비고", "text", new[] { "메모", "배송메모", "Remark" }, false, 500, null, null, null, false, true, true, false, null),
["shippedQty"] = new("shippedQty", "출고수량", "quantity", new[] { "ShippedQty" }, false, null, 18, 4, null, true, false, true, false, null),
["specification"] = new("specification", "규격", "text", new[] { "Specification", "Spec" }, false, 200, null, null, null, false, true, true, false, null),
["status"] = new("status", "상태", "status", new[] { "Status" }, false, null, null, null, null, true, false, true, false, null),
["totalQty"] = new("totalQty", "총수량", "quantity", new[] { "TotalQty" }, false, null, 18, 4, null, true, false, true, false, null),
["unitPrice"] = new("unitPrice", "단가", "money", new[] { "판매단가", "UnitPrice" }, false, null, 18, 4, null, false, true, true, false, null),
["updatedAt"] = new("updatedAt", "수정일시", "datetime", new[] { "UpdatedAt" }, false, null, null, null, null, true, false, true, false, null),
["updatedBy"] = new("updatedBy", "수정자", "text", new[] { "UpdatedBy" }, false, null, null, null, null, true, false, true, false, null),
["version"] = new("version", "버전", "integer", new[] { "Version", "RowVersion" }, false, null, null, null, null, true, false, false, false, null),
["warehouseCode"] = new("warehouseCode", "창고코드", "code", new[] { "창고", "창고코드", "출고창고", "WarehouseCode" }, true, 50, null, null, "warehouse", false, true, true, false, null),
["warehouseId"] = new("warehouseId", "창고ID", "lookup", new[] { "WarehouseId" }, false, null, null, null, "warehouse", true, false, false, false, null),
["warehouseName"] = new("warehouseName", "창고명", "text", new[] { "WarehouseName" }, false, 200, null, null, null, true, false, true, false, null),
};
public static KbxFieldMetadata Get(string key) => Items.TryGetValue(key, out var value)
? value
: throw new KeyNotFoundException($"Unknown KBX field key: {key}");
public static bool TryGet(string key, out KbxFieldMetadata? value) => Items.TryGetValue(key, out value);
public static IReadOnlyCollection<KbxFieldMetadata> All => Items.Values;
}
@@ -0,0 +1,78 @@
using System.Text.RegularExpressions;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Shared.Contracts.Generated;
using Shared.Problems;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Shared.Contracts;
public static class KbxApiContractSwaggerExtensions
{
public static void AddKbxApiContracts(this SwaggerGenOptions options)
{
options.OperationFilter<KbxApiContractOperationFilter>();
options.OperationFilter<KbxProblemOpenApiOperationFilter>();
}
}
public sealed class KbxApiContractOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var method = context.ApiDescription.HttpMethod?.ToUpperInvariant();
var relative = "/" + (context.ApiDescription.RelativePath ?? string.Empty).TrimStart('/');
var normalized = Normalize(relative);
var contract = KbxApiCatalog.All.FirstOrDefault(x =>
x.Method == method && Normalize(x.Path) == normalized);
if (contract is null) return;
operation.OperationId = contract.Id;
operation.Extensions["x-kbx-permission"] = new OpenApiString(contract.Permission ?? string.Empty);
operation.Extensions["x-kbx-idempotency"] = new OpenApiString(contract.Idempotency);
operation.Extensions["x-kbx-kind"] = new OpenApiString(contract.Kind);
}
private static string Normalize(string value) =>
Regex.Replace(value, @"\{([^}:]+):[^}]+\}", "{$1}").ToLowerInvariant();
}
public sealed class KbxProblemOpenApiOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var problem = new OpenApiSchema
{
OneOf = new List<OpenApiSchema>
{
context.SchemaGenerator.GenerateSchema(typeof(KbxValidationProblem), context.SchemaRepository),
context.SchemaGenerator.GenerateSchema(typeof(KbxBusinessProblem), context.SchemaRepository),
context.SchemaGenerator.GenerateSchema(typeof(KbxConflictProblem), context.SchemaRepository),
context.SchemaGenerator.GenerateSchema(typeof(KbxPermissionProblem), context.SchemaRepository),
context.SchemaGenerator.GenerateSchema(typeof(KbxNotFoundProblem), context.SchemaRepository),
context.SchemaGenerator.GenerateSchema(typeof(KbxIntegrationProblem), context.SchemaRepository),
context.SchemaGenerator.GenerateSchema(typeof(KbxSystemProblem), context.SchemaRepository),
}
};
Add(operation, "400", "Validation problem", problem);
Add(operation, "403", "Permission problem", problem);
Add(operation, "404", "Not found problem", problem);
Add(operation, "409", "Conflict or business rule problem", problem);
Add(operation, "422", "Business rule problem", problem);
Add(operation, "500", "System problem", problem);
}
private static void Add(OpenApiOperation operation, string status, string description, OpenApiSchema schema)
{
if (operation.Responses.ContainsKey(status)) return;
operation.Responses[status] = new OpenApiResponse
{
Description = description,
Content = new Dictionary<string, OpenApiMediaType>
{
["application/json"] = new() { Schema = schema }
}
};
}
}
@@ -0,0 +1,9 @@
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Kbx.Contracts;
public static class KbxFieldContractSwaggerExtensions
{
public static void AddKbxFieldContract(this SwaggerGenOptions options)
=> options.SchemaFilter<KbxFieldOpenApiSchemaFilter>();
}
@@ -0,0 +1,7 @@
namespace Kbx.Contracts;
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false)]
public sealed class KbxFieldKeyAttribute(string fieldKey) : Attribute
{
public string FieldKey { get; } = fieldKey;
}
@@ -0,0 +1,39 @@
using System.Reflection;
using System.Text.Json;
using Kbx.Contracts.Generated;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Kbx.Contracts;
/// <summary>
/// Adds non-business KBX field metadata to OpenAPI properties.
/// Domain validation rules are intentionally not emitted from the field dictionary.
/// </summary>
public sealed class KbxFieldOpenApiSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
foreach (var property in context.Type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
var attribute = property.GetCustomAttribute<KbxFieldKeyAttribute>();
if (attribute is null || !KbxFieldCatalog.TryGet(attribute.FieldKey, out var field) || field is null)
continue;
var jsonName = JsonNamingPolicy.CamelCase.ConvertName(property.Name);
if (!schema.Properties.TryGetValue(jsonName, out var propertySchema))
{
var match = schema.Properties.FirstOrDefault(x => string.Equals(x.Key, property.Name, StringComparison.OrdinalIgnoreCase));
propertySchema = match.Value;
}
if (propertySchema is null) continue;
propertySchema.Extensions["x-kbx-field-key"] = new OpenApiString(field.Key);
propertySchema.Extensions["x-kbx-field-label"] = new OpenApiString(field.Label);
propertySchema.Extensions["x-kbx-sensitive"] = new OpenApiBoolean(field.Sensitive);
if (!string.IsNullOrWhiteSpace(field.Masking))
propertySchema.Extensions["x-kbx-masking"] = new OpenApiString(field.Masking);
}
}
}
@@ -0,0 +1,21 @@
using Shared.Contracts.Generated;
using Xunit;
namespace Shared.Contracts.Tests;
public sealed class KbxApiCatalogTests
{
[Fact]
public void Operation_ids_and_routes_are_unique()
{
Assert.NotEmpty(KbxApiCatalog.SourceSha256);
Assert.Equal(KbxApiCatalog.All.Count, KbxApiCatalog.All.Select(x => x.Id).Distinct().Count());
Assert.Equal(KbxApiCatalog.All.Count, KbxApiCatalog.All.Select(x => $"{x.Method} {x.Path}").Distinct().Count());
}
[Fact]
public void Required_idempotency_is_not_used_by_get_queries()
{
Assert.DoesNotContain(KbxApiCatalog.All, x => x.Method == "GET" && x.Idempotency == "required");
}
}
@@ -0,0 +1,22 @@
using Kbx.Contracts.Generated;
using Xunit;
namespace Kbx.Contracts.Tests;
public sealed class KbxFieldCatalogTests
{
[Fact]
public void Sensitive_fields_have_masking_metadata()
{
var invalid = KbxFieldCatalog.All.Where(x => x.Sensitive && string.IsNullOrWhiteSpace(x.Masking)).ToArray();
Assert.Empty(invalid);
}
[Fact]
public void Order_quantity_is_canonical()
{
var field = KbxFieldCatalog.Get(KbxFieldKeys.OrderQty);
Assert.Equal("quantity", field.DataType);
Assert.True(field.Importable);
}
}