Let’s turn this into pasteable scaffolding that matches your actual controller style (ApiController, [RoutePrefix("dwapi/ecommerce/products")], etc.) and doesn’t depend on undocumented internals of ProductViewModelsExporter.
Below is a drop-in “bundle export” endpoint that:
-
accepts multiple ShopIds + LanguageIds
-
accepts field selection + optional labels
-
exports CSV or JSON
-
returns one ZIP download (single request, single response)
It reuses your existing product query logic (ViewModelFactory.CreateView(...)) so selection via Repository/Query, GroupId, ProductIds, etc. works the same way.
1) Add this to ProductsController.cs
Put the DTOs + helpers near the bottom of the controller (or in separate files if you prefer). Then add the endpoint.
DTOs
using System.IO;
using System.IO.Compression;
using System.Net.Http.Headers;
using System.Reflection;
using Newtonsoft.Json; // If you already use Newtonsoft. Otherwise switch to System.Text.Json.
// ...
public class ExportBundleRequest
{
// Multi selection
public List<string> LanguageIds { get; set; } = new List<string>();
public List<string> ShopIds { get; set; } = new List<string>();
// Export format: "csv" or "json"
public string Format { get; set; } = "csv";
// Selected fields (dot-path allowed, e.g. "Price.Price", "ProductFields.Color")
public List<string> Fields { get; set; } = new List<string>();
// Optional header labels for fields
public Dictionary<string, string> Labels { get; set; } = new Dictionary<string, string>();
// Product selection (same hierarchy you already use)
public string RepositoryName { get; set; }
public string QueryName { get; set; }
public string GroupId { get; set; }
public List<string> ProductIds { get; set; } = new List<string>();
public List<string> ProductAutoIds { get; set; } = new List<string>();
// Optional: file base name
public string FileName { get; set; } = "products";
}
// A super small response model if you later want "email link" instead of direct download.
public class ExportBundleResult
{
public string Message { get; set; }
}
The endpoint
[HttpPost, Route("exportbundle")]
[SwaggerResponse(HttpStatusCode.OK, typeof(HttpResponseMessage), Description = "ZIP containing one export file per shop/language.")]
[SwaggerResponse(HttpStatusCode.BadRequest, null, Description = "Invalid parameters.")]
[SwaggerResponse(HttpStatusCode.NotFound, null, Description = "No matching products found.")]
[SwaggerResponse(HttpStatusCode.NoContent, null, Description = "No products match the parameters.")]
public HttpResponseMessage ExportProductsBundle([FromBody] ExportBundleRequest model, [FromUri] ProductListViewModelSettings request)
{
if (!Security.Licensing.LicenseManager.LicenseHasFeature("DownloadCart"))
return Request.CreateResponse(HttpStatusCode.PaymentRequired, "You don't have a proper license to support this feature");
if (model == null)
return Request.CreateResponse(HttpStatusCode.BadRequest, "Missing body");
// Defaults / validation
var format = (model.Format ?? "csv").Trim().ToLowerInvariant();
if (format != "csv" && format != "json")
return Request.CreateResponse(HttpStatusCode.BadRequest, "Format must be 'csv' or 'json'");
if (model.Fields == null || model.Fields.Count == 0)
return Request.CreateResponse(HttpStatusCode.BadRequest, "Select at least one field to export");
// Build a list of (shopId, languageId) pairs.
// If you run "one shop per language", you can just pass LanguageIds and map to shops here instead.
var pairs = BuildPairs(model.ShopIds, model.LanguageIds);
if (pairs.Count == 0)
return Request.CreateResponse(HttpStatusCode.BadRequest, "Select at least one language/shop");
// Build the SearchRequest just like your existing ExportProductsPost does.
var searchRequest = new SearchRequest
{
RepositoryName = model.RepositoryName,
QueryName = model.QueryName,
GroupId = model.GroupId,
ProductIds = model.ProductIds,
ProductAutoIds = model.ProductAutoIds
};
// NOTE: request is your existing ProductListViewModelSettings (FromUri).
// We'll clone-per-iteration by copying and setting LanguageId/ShopId.
using (var zipMs = new MemoryStream())
using (var zip = new ZipArchive(zipMs, ZipArchiveMode.Create, leaveOpen: true))
{
bool anyProducts = false;
foreach (var (shopId, languageId) in pairs)
{
// Make a shallow copy of the settings object (manual copy avoids reflection surprises).
var iterRequest = CloneRequest(request);
iterRequest.ShopId = shopId;
iterRequest.LanguageId = languageId;
// Optional: If you want field selection to affect viewmodel filling, pass a filled properties param.
// Many DW viewmodels honor ProductSettings.FilledProperties; if yours does, add:
// iterRequest.ProductSettings.FilledProperties = string.Join(",", model.Fields);
// (Only do this if ProductSettings exists on your settings type.)
// This mirrors your existing ExportProducts(...) selection hierarchy
var vm = CreateProductListViewModel(searchRequest, iterRequest);
if (vm == null)
continue;
if (vm.Products == null || vm.Products.Count == 0)
continue;
anyProducts = true;
var fileName = MakeEntryFileName(model.FileName, shopId, languageId, format);
var entry = zip.CreateEntry(fileName, CompressionLevel.Optimal);
using (var entryStream = entry.Open())
using (var writer = new StreamWriter(entryStream))
{
if (format == "csv")
{
WriteCsv(writer, vm.Products, model.Fields, model.Labels);
}
else
{
WriteJson(writer, vm.Products, model.Fields, model.Labels);
}
}
}
if (!anyProducts)
return Request.CreateResponse(HttpStatusCode.NoContent);
zip.Dispose(); // finalize
var bytes = zipMs.ToArray();
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Content = new ByteArrayContent(bytes);
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
response.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
{
FileName = $"{(model.FileName ?? "products")}.zip"
};
return response;
}
}
Helpers used above
private static List<(string shopId, string languageId)> BuildPairs(List<string> shopIds, List<string> languageIds)
{
shopIds = shopIds?.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().ToList() ?? new List<string>();
languageIds = languageIds?.Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().ToList() ?? new List<string>();
var pairs = new List<(string, string)>();
// If both are provided, cross-join (explicit).
if (shopIds.Count > 0 && languageIds.Count > 0)
{
foreach (var s in shopIds)
foreach (var l in languageIds)
pairs.Add((s, l));
return pairs;
}
// If only one side is provided, create pairs with empty other part (or map later).
// In your "one shop per language" world, you can map language->shop here.
foreach (var s in shopIds)
pairs.Add((s, null));
foreach (var l in languageIds)
pairs.Add((null, l));
return pairs;
}
private ProductListViewModelSettings CloneRequest(ProductListViewModelSettings request)
{
// Manual shallow clone (copy properties you actually use).
// Add more properties if your ProductListViewModelSettings has relevant fields.
if (request == null)
request = new ProductListViewModelSettings();
return new ProductListViewModelSettings
{
ShopId = request.ShopId,
LanguageId = request.LanguageId,
CurrencyCode = request.CurrencyCode,
CountryCode = request.CountryCode,
// PageSize etc. if present
PageSize = request.PageSize,
PageNumber = request.PageNumber
// Add others as needed
};
}
private ProductListViewModel CreateProductListViewModel(SearchRequest searchRequest, ProductListViewModelSettings request)
{
// Same hierarchy as your ExportProducts()
if (!string.IsNullOrEmpty(searchRequest.RepositoryName) && !string.IsNullOrEmpty(searchRequest.QueryName))
return ViewModelFactory.CreateView(request, searchRequest.RepositoryName, searchRequest.QueryName);
if (!string.IsNullOrEmpty(searchRequest.GroupId))
return ViewModelFactory.CreateView(request, searchRequest.GroupId);
if (searchRequest.ProductAutoIds != null && searchRequest.ProductAutoIds.Count > 0)
return ViewModelFactory.CreateView(request, searchRequest.ProductAutoIds);
if (searchRequest.ProductIds != null && searchRequest.ProductIds.Count > 0)
return ViewModelFactory.CreateView(request, searchRequest.ProductIds);
return null;
}
private static string MakeEntryFileName(string baseName, string shopId, string languageId, string format)
{
baseName = string.IsNullOrWhiteSpace(baseName) ? "products" : baseName.Trim();
var s = string.IsNullOrWhiteSpace(shopId) ? "shop" : shopId;
var l = string.IsNullOrWhiteSpace(languageId) ? "lang" : languageId;
return $"{baseName}_{s}_{l}.{format}";
}
CSV/JSON writers (field selection + labels)
private static void WriteCsv(TextWriter writer, IList<object> products, List<string> fields, Dictionary<string, string> labels)
{
// Header
var header = fields.Select(f => EscapeCsv(labels != null && labels.TryGetValue(f, out var lbl) ? lbl : f));
writer.WriteLine(string.Join(";", header)); // Use ';' (common in DK/Excel). Change if you need ','.
foreach (var p in products)
{
var row = fields.Select(f => EscapeCsv(Convert.ToString(ReadPath(p, f) ?? "")));
writer.WriteLine(string.Join(";", row));
}
}
private static void WriteJson(TextWriter writer, IList<object> products, List<string> fields, Dictionary<string, string> labels)
{
// Output an array of objects with label-aware keys (labels override field key)
var rows = new List<Dictionary<string, object>>();
foreach (var p in products)
{
var obj = new Dictionary<string, object>();
foreach (var f in fields)
{
var key = (labels != null && labels.TryGetValue(f, out var lbl) && !string.IsNullOrWhiteSpace(lbl)) ? lbl : f;
obj[key] = ReadPath(p, f);
}
rows.Add(obj);
}
// If you don’t want Newtonsoft, swap to System.Text.Json
var json = JsonConvert.SerializeObject(rows, Formatting.Indented);
writer.Write(json);
}
private static object ReadPath(object obj, string path)
{
if (obj == null || string.IsNullOrWhiteSpace(path))
return null;
object current = obj;
foreach (var part in path.Split('.'))
{
if (current == null) return null;
// IDictionary support (Dynamicweb models sometimes expose dictionaries)
if (current is System.Collections.IDictionary dict)
{
if (!dict.Contains(part)) return null;
current = dict[part];
continue;
}
var t = current.GetType();
var prop = t.GetProperty(part, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (prop == null) return null;
current = prop.GetValue(current);
}
return current;
}
private static string EscapeCsv(string s)
{
if (s == null) return "";
if (s.Contains(";") || s.Contains("\"") || s.Contains("\n") || s.Contains("\r"))
return "\"" + s.Replace("\"", "\"\"") + "\"";
return s;
}
2) Update DownloadCart.cshtml to use the new endpoint
Minimal changes:
A) Post to the new endpoint
<form method="post" action="/dwapi/ecommerce/products/exportbundle?ShopId=@shopId&LanguageId=@languageId">
Becomes something like:
<form method="post" action="/dwapi/ecommerce/products/exportbundle">
…and send a JSON body (best) or plain form fields. If you want to stay form-only, you can still post form-url-encoded, but JSON is cleaner for arrays + labels.
B) Multi-select languages + shops (form-friendly)
<select name="LanguageIds" multiple>
@* options *@
</select>
<select name="ShopIds" multiple>
@* options *@
</select>
C) Field picker + label inputs
@{
var fields = new[] { "Id", "Number", "Name", "ShortDescription", "Price.Price" };
}
@foreach (var f in fields)
{
<div>
<label>
<input type="checkbox" name="Fields" value="@f" />
@f
</label>
<input type="text" name="Labels[@f]" placeholder="Custom column label (optional)" />
</div>
}
D) Format
<select name="Format">
<option value="csv">CSV</option>
<option value="json">JSON</option>
</select>
E) Keep your existing product selection inputs
Whatever you currently post for GroupId, RepositoryName, QueryName, ProductIds, etc., keep doing that — just ensure they map to ExportBundleRequest.
3) Two practical “DW9 reality” tweaks I’d do immediately
-
Kill the PageSize=100 trap for exports. Either remove it for this bundle export or set it explicitly high / implement paging.
-
Whitelist fields. Don’t allow arbitrary reflection paths from users unless you enjoy surprise data leaks and long coffees.s type.