mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 11:31:07 -04:00
A drawing set is a named list of library drawings with quantities (a kit,
an assembly, a recurring order) that will later be released into one nest
per material. This is the server half: sets get the same revision,
If-Match and archive contract as /api/drawings so the client and tests
follow a known shape.
Schema v7 adds drawing_sets, drawing_set_revisions and
drawing_set_members. Members reference drawings by id and mean each
drawing's current revision, so reads join the current drawing record:
the client can group members by material without one request per drawing,
and the joined record is ignored on save so a read record can be sent
back unchanged. Every change keeps its own member list, and
/api/drawings/{id}/sets answers "used in" from current revisions only.
The drawing query's enum, integer and history-paging parsing is now shared
so both APIs refuse bad input identically.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
250 lines
8.7 KiB
C#
250 lines
8.7 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using OpenNest;
|
|
using OpenNest.Data;
|
|
|
|
namespace OpenNest.Server;
|
|
|
|
/// <summary>
|
|
/// Parses drawing library requests. Unknown, repeated or out-of-range values are
|
|
/// rejected rather than ignored or clamped, and enum fields accept names only.
|
|
/// </summary>
|
|
internal static class DrawingLibraryRequest
|
|
{
|
|
private static readonly string[] QueryKeys =
|
|
{
|
|
"search", "customer", "materialName", "materialGrade", "thickness", "cutType", "cutFirst",
|
|
"includeArchived", "sort", "order", "offset", "limit",
|
|
};
|
|
|
|
// Integer enum values are refused; names are additionally checked before deserializing.
|
|
private static readonly JsonSerializerOptions MetadataOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) },
|
|
};
|
|
|
|
public static bool TryParseQuery(IQueryCollection values, out DrawingLibraryQuery query, out string error)
|
|
{
|
|
query = new DrawingLibraryQuery();
|
|
foreach (var key in values.Keys)
|
|
{
|
|
if (!QueryKeys.Contains(key, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
error = $"Unknown query parameter '{key}'.";
|
|
return false;
|
|
}
|
|
|
|
if (values[key].Count > 1)
|
|
{
|
|
error = $"Query parameter '{key}' was given more than once.";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
var sort = DrawingSortField.SavedAt;
|
|
if (values.TryGetValue("sort", out var sortText) && !TryEnumName(sortText.ToString(), out sort))
|
|
{
|
|
error = $"sort must be one of: {string.Join(", ", Enum.GetNames<DrawingSortField>().Select(CamelCase))}.";
|
|
return false;
|
|
}
|
|
|
|
var descending = true;
|
|
if (values.TryGetValue("order", out var orderText))
|
|
{
|
|
if (string.Equals(orderText, "asc", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
descending = false;
|
|
}
|
|
else if (!string.Equals(orderText, "desc", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
error = "order must be asc or desc.";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
CutType? cutType = null;
|
|
if (values.TryGetValue("cutType", out var cutTypeText))
|
|
{
|
|
if (!TryEnumName<CutType>(cutTypeText.ToString(), out var parsed))
|
|
{
|
|
error = "cutType must be Laser, Waterjet or Plasma.";
|
|
return false;
|
|
}
|
|
|
|
cutType = parsed;
|
|
}
|
|
|
|
double? thickness = null;
|
|
if (values.TryGetValue("thickness", out var thicknessText))
|
|
{
|
|
if (!double.TryParse(thicknessText.ToString(), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var parsed)
|
|
|| !double.IsFinite(parsed))
|
|
{
|
|
error = "thickness must be a finite number, zero or greater.";
|
|
return false;
|
|
}
|
|
|
|
thickness = parsed;
|
|
}
|
|
|
|
var includeArchived = false;
|
|
if (values.TryGetValue("includeArchived", out var archivedText) && !bool.TryParse(archivedText.ToString(), out includeArchived))
|
|
{
|
|
error = "includeArchived must be true or false.";
|
|
return false;
|
|
}
|
|
|
|
bool? cutFirst = null;
|
|
if (values.TryGetValue("cutFirst", out var cutFirstText))
|
|
{
|
|
if (!bool.TryParse(cutFirstText.ToString(), out var parsed))
|
|
{
|
|
error = "cutFirst must be true or false.";
|
|
return false;
|
|
}
|
|
|
|
cutFirst = parsed;
|
|
}
|
|
|
|
var offset = 0;
|
|
var limit = DrawingLibraryQuery.DefaultLimit;
|
|
if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error))
|
|
return false;
|
|
|
|
query = new DrawingLibraryQuery
|
|
{
|
|
Search = values["search"].ToString(),
|
|
Customer = values["customer"].ToString(),
|
|
MaterialName = values["materialName"].ToString(),
|
|
MaterialGrade = values["materialGrade"].ToString(),
|
|
Thickness = thickness,
|
|
CutType = cutType,
|
|
CutFirst = cutFirst,
|
|
IncludeArchived = includeArchived,
|
|
Sort = sort,
|
|
Descending = descending,
|
|
Offset = offset,
|
|
Limit = limit,
|
|
};
|
|
error = query.GetValidationError() ?? "";
|
|
return error.Length == 0;
|
|
}
|
|
|
|
/// <summary>Reads and validates one metadata JSON object.</summary>
|
|
public static bool TryReadMetadata(string? json, out DrawingLibraryRecord record, out string error)
|
|
{
|
|
record = new DrawingLibraryRecord();
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
{
|
|
error = "Missing metadata.";
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(json);
|
|
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
|
{
|
|
error = "Metadata must be a JSON object.";
|
|
return false;
|
|
}
|
|
|
|
// Enum converters also accept comma-separated combinations; allow single names only.
|
|
foreach (var property in document.RootElement.EnumerateObject())
|
|
{
|
|
if (string.Equals(property.Name, "cutType", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (property.Value.ValueKind != JsonValueKind.String || !TryEnumName<CutType>(property.Value.GetString()!, out _))
|
|
{
|
|
error = "cutType must be Laser, Waterjet or Plasma.";
|
|
return false;
|
|
}
|
|
}
|
|
else if (string.Equals(property.Name, "units", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (property.Value.ValueKind != JsonValueKind.String || !TryEnumName<Units>(property.Value.GetString()!, out _))
|
|
{
|
|
error = "units must be a supported unit name.";
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
record = document.RootElement.Deserialize<DrawingLibraryRecord>(MetadataOptions) ?? record;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
error = "Invalid metadata JSON.";
|
|
return false;
|
|
}
|
|
|
|
error = DrawingLibraryRules.GetMetadataError(record) ?? "";
|
|
return error.Length == 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads history-style paging: only <c>offset</c> (zero or greater) and <c>limit</c>
|
|
/// (1 to <see cref="DrawingLibraryRules.MaxRevisionLimit"/>), each at most once.
|
|
/// </summary>
|
|
public static bool TryReadPaging(IQueryCollection values, out int offset, out int limit, out string error)
|
|
{
|
|
offset = 0;
|
|
limit = DrawingLibraryRules.DefaultRevisionLimit;
|
|
error = "";
|
|
foreach (var parameter in values)
|
|
{
|
|
if (parameter.Value.Count != 1
|
|
|| !int.TryParse(parameter.Value[0], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value))
|
|
{
|
|
error = "Invalid history pagination.";
|
|
return false;
|
|
}
|
|
|
|
switch (parameter.Key)
|
|
{
|
|
case "offset": offset = value; break;
|
|
case "limit": limit = value; break;
|
|
default:
|
|
error = "Unknown history parameter.";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit)
|
|
{
|
|
error = "Invalid history pagination.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Names only: Enum.TryParse would also accept numbers, combinations and undefined values.
|
|
internal static bool TryEnumName<TEnum>(string text, out TEnum value)
|
|
where TEnum : struct, Enum
|
|
{
|
|
value = default;
|
|
return text.Length > 0
|
|
&& text.All(char.IsAsciiLetter)
|
|
&& Enum.TryParse(text, ignoreCase: true, out value)
|
|
&& Enum.IsDefined(value);
|
|
}
|
|
|
|
internal static string CamelCase(string name) => char.ToLowerInvariant(name[0]) + name[1..];
|
|
|
|
internal static bool TryInteger(IQueryCollection values, string key, ref int value, out string error)
|
|
{
|
|
error = "";
|
|
if (!values.TryGetValue(key, out var text))
|
|
return true;
|
|
|
|
if (int.TryParse(text.ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out value))
|
|
return true;
|
|
|
|
error = $"{key} must be a non-negative integer.";
|
|
return false;
|
|
}
|
|
}
|