using System.Collections.Generic; using System.Globalization; using System.Linq; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Primitives; namespace Prefab.Web.UrlPolicy; /// /// Provides centralized helpers for building canonical catalog URLs used by the BFF. /// public static class UrlPolicy { public const int DefaultPage = 1; public const int DefaultPageSize = 24; public const string DefaultSort = "name:asc"; public const string DefaultView = "grid"; private static readonly int[] AllowedPageSizes = [12, 24, 48]; /// /// Builds a canonical catalog URL for navigating to a category landing page. /// /// The category slug to embed in the query string. public static string BrowseCategory(string slug) { ArgumentException.ThrowIfNullOrWhiteSpace(slug); return $"/catalog/category?category-slug={Uri.EscapeDataString(slug)}"; } /// /// Builds a canonical catalog URL for navigating to the products grid for a category. /// /// The slug of the category whose products should be displayed. public static string BrowseProducts(string categorySlug) { ArgumentException.ThrowIfNullOrWhiteSpace(categorySlug); return $"/catalog/products?category-slug={Uri.EscapeDataString(categorySlug)}"; } /// /// Builds a canonical catalog URL for navigating to a specific product detail page. /// /// The slug of the product. public static string BrowseProduct(string slug) { ArgumentException.ThrowIfNullOrWhiteSpace(slug); return $"/browse/product?slug={Uri.EscapeDataString(slug)}"; } /// /// Builds a canonical catalog URL for the category browsing experience including paging state. /// public static string Category( string slug, int page = DefaultPage, int pageSize = DefaultPageSize, string? sort = null, string? view = null) { ArgumentException.ThrowIfNullOrWhiteSpace(slug); var normalizedPage = NormalizePage(page); var normalizedPageSize = NormalizePageSize(pageSize); var normalizedSort = NormalizeSort(sort); var normalizedView = NormalizeView(view); var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["slug"] = slug, ["page"] = normalizedPage.ToString(CultureInfo.InvariantCulture), ["pageSize"] = normalizedPageSize.ToString(CultureInfo.InvariantCulture), ["sort"] = normalizedSort, ["view"] = normalizedView }; return QueryHelpers.AddQueryString("/catalog/category", parameters); } /// /// Attempts to parse the catalog category browse URL parameters. /// public static bool TryParseCategory( Uri uri, out string slug, out int page, out int pageSize, out string sort, out string view) { ArgumentNullException.ThrowIfNull(uri); slug = string.Empty; page = DefaultPage; pageSize = DefaultPageSize; sort = DefaultSort; view = DefaultView; var queryValues = ParseQuery(uri); if (!queryValues.TryGetValue("slug", out var slugValues)) { return false; } var candidateSlug = slugValues.ToString(); if (string.IsNullOrWhiteSpace(candidateSlug)) { return false; } slug = candidateSlug; page = NormalizePage(ParseInt(queryValues, "page", DefaultPage)); pageSize = NormalizePageSize(ParseInt(queryValues, "pageSize", DefaultPageSize)); sort = NormalizeSort(queryValues.TryGetValue("sort", out var sortValues) ? sortValues.ToString() : null); view = NormalizeView(queryValues.TryGetValue("view", out var viewValues) ? viewValues.ToString() : null); return true; } /// /// Builds a canonical catalog URL for browsing products without a category context. /// public static string Products( int page = DefaultPage, int pageSize = DefaultPageSize, string? sort = null, string? view = null) { var normalizedPage = NormalizePage(page); var normalizedPageSize = NormalizePageSize(pageSize); var normalizedSort = NormalizeSort(sort); var normalizedView = NormalizeView(view); var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["page"] = normalizedPage.ToString(CultureInfo.InvariantCulture), ["pageSize"] = normalizedPageSize.ToString(CultureInfo.InvariantCulture), ["sort"] = normalizedSort, ["view"] = normalizedView }; return QueryHelpers.AddQueryString("/catalog/products", parameters); } /// /// Attempts to parse the products browse URL parameters. /// public static bool TryParseProducts( Uri uri, out int page, out int pageSize, out string sort, out string view) { ArgumentNullException.ThrowIfNull(uri); page = DefaultPage; pageSize = DefaultPageSize; sort = DefaultSort; view = DefaultView; var queryValues = ParseQuery(uri); page = NormalizePage(ParseInt(queryValues, "page", DefaultPage)); pageSize = NormalizePageSize(ParseInt(queryValues, "pageSize", DefaultPageSize)); sort = NormalizeSort(queryValues.TryGetValue("sort", out var sortValues) ? sortValues.ToString() : null); view = NormalizeView(queryValues.TryGetValue("view", out var viewValues) ? viewValues.ToString() : null); return true; } private static IDictionary ParseQuery(Uri uri) { var querySegment = uri.IsAbsoluteUri ? uri.Query : uri.OriginalString; if (string.IsNullOrEmpty(querySegment)) { return new Dictionary(StringComparer.OrdinalIgnoreCase); } if (!uri.IsAbsoluteUri) { var questionIndex = querySegment.IndexOf('?', StringComparison.Ordinal); querySegment = questionIndex >= 0 ? querySegment[questionIndex..] : string.Empty; } if (string.IsNullOrEmpty(querySegment)) { return new Dictionary(StringComparer.OrdinalIgnoreCase); } return QueryHelpers.ParseQuery(querySegment); } private static int ParseInt(IDictionary values, string key, int fallback) { if (values.TryGetValue(key, out var raw) && int.TryParse(raw.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) { return parsed; } return fallback; } private static int NormalizePage(int page) => page < 1 ? DefaultPage : page; private static int NormalizePageSize(int pageSize) => AllowedPageSizes.Contains(pageSize) ? pageSize : DefaultPageSize; private static string NormalizeSort(string? sort) => string.IsNullOrWhiteSpace(sort) ? DefaultSort : sort.Trim(); private static string NormalizeView(string? view) => string.IsNullOrWhiteSpace(view) ? DefaultView : view.Trim().ToLowerInvariant(); }