@@ -354,6 +354,15 @@ import { prepareImportRequest } from './import-prepare.js';
354354// measurement that decides its shape.
355355import { datasetSelectionRefusal } from './analytics-selection-door.js';
356356import { loadExcelJs, type Worksheet } from './xlsx-module.js';
357+ // [#18386] `?template=true` on the export door: the import template's column
358+ // rule, request reading and workbook. See the module header.
359+ import {
360+ buildImportTemplateWorkbook,
361+ describeTemplateColumns,
362+ readTemplateMode,
363+ templateColumns,
364+ templateText,
365+ } from './import-template.js';
357366import { enrichOpenApiWithEndpoints } from './openapi-endpoints.js';
358367import { buildBuiltinPaths } from './openapi-builtin-paths.js';
359368import {
@@ -629,13 +638,19 @@ export const DATA_RECORD_READ_PARAMS: readonly string[] = ['select', 'expand'];
629638 * a loud export outage — the preservation half of
630639 * `rest-server-closed-query-params.test.ts` exists to make that impossible to
631640 * land, and pins `locale` by name for the reason above.
641+ *
642+ * [#18386] …and `template`, the mode switch: `template=true` answers an xlsx
643+ * IMPORT template (`./import-template.ts`) instead of the data. It is read by
644+ * `readTemplateMode`, which also refuses the row parameters above on a
645+ * template request, since a template has no rows for them to select.
632646 */
633647export const DATA_EXPORT_PARAMS: readonly string[] = [
634648 'format', 'header',
635649 'limit', 'page',
636650 'filter', 'search', 'searchFields', 'orderby',
637651 'fields',
638652 'locale',
653+ 'template',
639654];
640655
641656/**
@@ -9571,6 +9586,8 @@ export class RestServer {
95719586 // header=false (omit the header row for csv / xlsx; default true)
95729587 // limit=<n> (default 10000, hard cap 50000)
95739588 // page=<n> (driver chunk size, default 500, max 5000)
9589+ // template=true (an xlsx IMPORT template instead of the data — see
9590+ // `answerImportTemplate`; `false` or absent is the export)
95749591 //
95759592 // Values are formatted for readability from the object schema: lookup /
95769593 // user fields resolve to a name (via injected $expand), select fields to
@@ -9639,8 +9656,21 @@ export class RestServer {
96399656 // and still answers what it answered before.
96409657 if (refuseUnknownQueryParams(req, res, DATA_EXPORT_PARAMS)) return;
96419658 if (refuseRepeatedQueryParams(req, res,
9642- ['format', 'header', 'limit', 'page', 'filter', 'search', 'orderby'])) return;
9659+ ['format', 'header', 'limit', 'page', 'filter', 'search', 'orderby', 'template' ])) return;
96439660 const q = req.query ?? {};
9661+ // [#18386] `?template=true` answers the import template and
9662+ // returns before a single export header is set, so without it
9663+ // everything below runs exactly as it did before the mode
9664+ // existed.
9665+ const templateMode = readTemplateMode(q);
9666+ if (templateMode.kind === 'refused') {
9667+ res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: templateMode.message } });
9668+ return;
9669+ }
9670+ if (templateMode.kind === 'template') {
9671+ await this.answerImportTemplate(req, res, p, environmentId, objectName, context, q);
9672+ return;
9673+ }
96449674 const fmtRaw = String(q.format ?? 'csv').toLowerCase();
96459675 const format: 'csv' | 'json' | 'xlsx' =
96469676 fmtRaw === 'json' ? 'json' : fmtRaw === 'xlsx' ? 'xlsx' : 'csv';
@@ -10004,6 +10034,124 @@ export class RestServer {
1000410034 });
1000510035 }
1000610036
10037+ /**
10038+ * [#18386] `GET {basePath}/data/:object/export?template=true` — the IMPORT
10039+ * template: an xlsx workbook with a header row of the columns an import can
10040+ * write, one example row, dropdowns for the closed value domains, and an
10041+ * instructions sheet. No data is read.
10042+ *
10043+ * It runs behind the export door's two gates, unchanged — the object's
10044+ * `export` exposure ({@link enforceApiAccess}) and the caller's export
10045+ * permission ({@link enforceExportPermission}) — and after the query-string
10046+ * gates, so it is reached only by a request the export would have served.
10047+ *
10048+ * Columns: an explicit `?fields=` is honoured as asked; otherwise
10049+ * `templateColumns` over the object as this caller reads it, narrowed by
10050+ * the security service's field projection.
10051+ *
10052+ * ⚠️ That projection is the READ one (`getReadableFields`). The card asks
10053+ * for the fields the caller may WRITE, and the security service answers no
10054+ * such question: its contract (`ISecurityService`) carries a readable-field
10055+ * projection and nothing for writes, and the write-side field mask lives
10056+ * inside `@objectstack/plugin-security`. Until a write projection exists
10057+ * there, a field the caller may read but not edit is still a column here,
10058+ * and the import refuses a row that fills it (403, field write denied). The
10059+ * read projection is kept because it is the one this door can ask: without
10060+ * it the header would name a field the caller cannot even see.
10061+ *
10062+ * A security service that is present but gives no projection fails the
10063+ * request rather than answering an unnarrowed header.
10064+ */
10065+ private async answerImportTemplate(
10066+ req: any,
10067+ res: any,
10068+ p: RestProtocol,
10069+ environmentId: string | undefined,
10070+ objectName: string,
10071+ context: any,
10072+ q: Record<string, any>,
10073+ ): Promise<void> {
10074+ let explicitFields: string[] | undefined;
10075+ if (typeof q.fields === 'string' && q.fields.length > 0) {
10076+ explicitFields = q.fields.split(',').map((s: string) => s.trim()).filter(Boolean);
10077+ } else if (Array.isArray(q.fields)) {
10078+ explicitFields = q.fields.filter((s: any) => typeof s === 'string' && s.length > 0);
10079+ }
10080+
10081+ // The object as the export reads it (registry first, `getObjectSchema`
10082+ // as the last resort), localized to the request — but NOT best-effort:
10083+ // a template without the schema has no columns to offer.
10084+ let schema: any = undefined;
10085+ if (typeof (p as any).getMetaItem === 'function') {
10086+ const found: any = await (p as any).getMetaItem({ type: 'object', name: objectName });
10087+ schema = found?.item;
10088+ }
10089+ if (!schema && typeof (p as any).getObjectSchema === 'function') {
10090+ schema = await (p as any).getObjectSchema(objectName, environmentId);
10091+ }
10092+ if (!schema || typeof schema !== 'object') {
10093+ const missing: any = new Error(`Object '${objectName}' was not found, so it has no import template.`);
10094+ missing.code = 'OBJECT_NOT_FOUND';
10095+ missing.status = 404;
10096+ throw missing;
10097+ }
10098+ schema = await this.translateMetaItem(req, 'object', environmentId, schema);
10099+
10100+ let permitted: ReadonlySet<string> | undefined;
10101+ if (!explicitFields || explicitFields.length === 0) {
10102+ const security = await this.resolveSecurityService(environmentId, req);
10103+ if (security && typeof security.getReadableFields === 'function') {
10104+ const readable = await security.getReadableFields(objectName, context);
10105+ if (!Array.isArray(readable)) {
10106+ throw new Error(
10107+ `The security service gave no field projection for '${objectName}', `
10108+ + 'so the import template cannot tell which columns this caller may see.',
10109+ );
10110+ }
10111+ permitted = new Set(readable);
10112+ }
10113+ }
10114+ const fields = templateColumns(schema, { explicitFields, permitted });
10115+
10116+ // A reference column names the object it points at by that object's
10117+ // label, when it can be read; otherwise by its name.
10118+ const referenceLabels = new Map<string, string>();
10119+ const metaMap = buildFieldMetaMap(schema);
10120+ for (const f of fields) {
10121+ const target = metaMap.get(f)?.reference;
10122+ if (!target || referenceLabels.has(target)) continue;
10123+ let label = target;
10124+ try {
10125+ const found: any = typeof (p as any).getMetaItem === 'function'
10126+ ? await (p as any).getMetaItem({ type: 'object', name: target })
10127+ : undefined;
10128+ const translated: any = found?.item
10129+ ? await this.translateMetaItem(req, 'object', environmentId, found.item)
10130+ : undefined;
10131+ if (typeof translated?.label === 'string' && translated.label.trim().length > 0) label = translated.label;
10132+ } catch { /* the target's name stands in for its label */ }
10133+ referenceLabels.set(target, label);
10134+ }
10135+
10136+ const i18n = await this.resolveI18nService(environmentId, req).catch(() => undefined);
10137+ const locale = this.extractLocale(req, i18n);
10138+ const columns = describeTemplateColumns(schema, fields, { locale, referenceLabels });
10139+ const workbook = await buildImportTemplateWorkbook(columns, { locale });
10140+ const bytes = Buffer.from(await workbook.xlsx.writeBuffer());
10141+
10142+ const timezone = typeof context?.timezone === 'string' && context.timezone ? String(context.timezone) : undefined;
10143+ const objectLabel = typeof schema.label === 'string' && schema.label.length > 0 ? schema.label : objectName;
10144+ res.header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
10145+ res.header('Content-Disposition', exportContentDisposition(
10146+ `${objectName}-template`, `${objectLabel}-${templateText(locale).filenameSuffix}`, 'xlsx', timezone,
10147+ ));
10148+ res.header('X-Export-Format', 'xlsx');
10149+ res.header('X-Export-Template', 'true');
10150+ res.header('Cache-Control', 'no-store');
10151+ res.write(bytes);
10152+ res.end();
10153+ }
10154+
1000710155 /**
1000810156 * [#3547] Resolve the environment's `security` service — the ENVIRONMENT's
1000910157 * kernel service first (its evaluator / FieldMasker are bound to that
0 commit comments