using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using OpenNest; using OpenNest.Data; namespace OpenNest.Server; /// /// Parses drawing library requests. Unknown, repeated or out-of-range values are /// rejected rather than ignored or clamped, and enum fields accept names only. /// internal static class DrawingLibraryRequest { private static readonly string[] QueryKeys = { "search", "customer", "materialName", "materialGrade", "thickness", "cutType", "cutFirst", "includeArchived", "sort", "order", "offset", "limit", "minThickness", "maxThickness", "includeBlankThickness", "minLength", "maxLength", "minWidth", "maxWidth", "minHoles", "maxHoles", "savedFrom", "savedTo", }; // 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().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(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; if (!TryReadFilter(values, out var filter, 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, Filter = filter, }; error = query.GetValidationError() ?? ""; return error.Length == 0; } // Thickness and sizes are inches; holes whole numbers; dates yyyy-MM-dd. The customer stays on the query. private static bool TryReadFilter(IQueryCollection values, out DrawingLibraryFilter filter, out string error) { filter = DrawingLibraryFilter.None; double? minThickness = null, maxThickness = null, minLength = null, maxLength = null, minWidth = null, maxWidth = null; int? minHoles = null, maxHoles = null; DateOnly? savedFrom = null, savedTo = null; var includeBlank = true; if (!TryNumber(values, "minThickness", ref minThickness, out error) || !TryNumber(values, "maxThickness", ref maxThickness, out error) || !TryNumber(values, "minLength", ref minLength, out error) || !TryNumber(values, "maxLength", ref maxLength, out error) || !TryNumber(values, "minWidth", ref minWidth, out error) || !TryNumber(values, "maxWidth", ref maxWidth, out error) || !TryCount(values, "minHoles", ref minHoles, out error) || !TryCount(values, "maxHoles", ref maxHoles, out error) || !TryDate(values, "savedFrom", ref savedFrom, out error) || !TryDate(values, "savedTo", ref savedTo, out error)) return false; if (values.TryGetValue("includeBlankThickness", out var blankText) && !bool.TryParse(blankText.ToString(), out includeBlank)) { error = "includeBlankThickness must be true or false."; return false; } filter = new DrawingLibraryFilter { MinThickness = minThickness, MaxThickness = maxThickness, IncludeBlankThickness = includeBlank, MinLength = minLength, MaxLength = maxLength, MinWidth = minWidth, MaxWidth = maxWidth, MinHoles = minHoles, MaxHoles = maxHoles, SavedFrom = savedFrom, SavedTo = savedTo, }; return true; } private static bool TryNumber(IQueryCollection values, string key, ref double? value, out string error) { error = ""; if (!values.TryGetValue(key, out var text)) return true; if (double.TryParse(text.ToString(), NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var parsed) && double.IsFinite(parsed)) { value = parsed; return true; } error = $"{key} must be a finite number, zero or greater."; return false; } private static bool TryCount(IQueryCollection values, string key, ref int? value, out string error) { var parsed = 0; if (!values.ContainsKey(key)) { error = ""; return true; } if (!TryInteger(values, key, ref parsed, out error)) return false; value = parsed; return true; } private static bool TryDate(IQueryCollection values, string key, ref DateOnly? value, out string error) { error = ""; if (!values.TryGetValue(key, out var text)) return true; if (DateOnly.TryParseExact(text.ToString(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) { value = parsed; return true; } error = $"{key} must be a date written yyyy-MM-dd."; return false; } /// Reads and validates one metadata JSON object. 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(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(property.Value.GetString()!, out _)) { error = "units must be a supported unit name."; return false; } } } record = document.RootElement.Deserialize(MetadataOptions) ?? record; } catch (JsonException) { error = "Invalid metadata JSON."; return false; } error = DrawingLibraryRules.GetMetadataError(record) ?? ""; return error.Length == 0; } /// /// Reads history-style paging: only offset (zero or greater) and limit /// (1 to ), each at most once. /// 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(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; } }