Skip to content

Commit 31c87c7

Browse files
committed
feat(rest): GET /data/:object/export?template=true answers the import template (wip)
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc0f030 commit 31c87c7

1 file changed

Lines changed: 149 additions & 1 deletion

File tree

‎packages/rest/src/rest-server.ts‎

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,15 @@ import { prepareImportRequest } from './import-prepare.js';
354354
// measurement that decides its shape.
355355
import { datasetSelectionRefusal } from './analytics-selection-door.js';
356356
import { 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';
357366
import { enrichOpenApiWithEndpoints } from './openapi-endpoints.js';
358367
import { buildBuiltinPaths } from './openapi-builtin-paths.js';
359368
import {
@@ -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
*/
633647
export 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

Comments
 (0)