feat(bridge): allow slug -> table_id resolution via BASEROW_TABLE_IDS — Patch 029

Callers can now hit /api/v1/views/table/personne instead of /table/609
and the bridge resolves the slug to the numeric Baserow table ID using
the BASEROW_TABLE_IDS env var (already present, just unused before).

Resolution accepts either a digits-only string (preserved behavior) or
a slug. Falls back to a 400 validation error when neither matches.

Note: this fixes the *routing* layer only. The downstream Baserow API
still requires a user JWT for some operations (notably GET /views/table)
and returns 401 PERMISSION_DENIED with the current DB-token-based bridge
config. That's a separate architectural concern — tracked for a later
patch (bridge user-JWT exchange).

Patch 029.
This commit is contained in:
Corentin JOGUET 2026-05-08 13:27:41 +02:00
parent 3ea5f822f1
commit 30b148694c
2 changed files with 45 additions and 3 deletions

View file

@ -39,6 +39,27 @@ const ConfigSchema = z.object({
// global (XADD MAXLEN ~). Défaut 10 000 events. Les events plus anciens sont // global (XADD MAXLEN ~). Défaut 10 000 events. Les events plus anciens sont
// purgés automatiquement par Redis (mode ~ = approximatif, plus performant). // purgés automatiquement par Redis (mode ~ = approximatif, plus performant).
streamMaxLen: z.coerce.number().int().positive().default(10_000), streamMaxLen: z.coerce.number().int().positive().default(10_000),
// Optional slug -> table_id map so callers can use human-friendly slugs
// (e.g. /api/v1/views/table/personne) instead of the numeric Baserow ID.
// Format: JSON object string like '{"personne":609,"formation":610}'.
baserowTableIds: z
.string()
.optional()
.transform((raw) => {
if (!raw) return {} as Record<string, number>;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const out: Record<string, number> = {};
for (const [k, v] of Object.entries(parsed)) {
if (typeof v === 'number' && Number.isInteger(v) && v > 0) {
out[k.toLowerCase()] = v;
}
}
return out;
} catch {
return {} as Record<string, number>;
}
}),
}); });
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;
@ -68,6 +89,7 @@ export function loadConfig(): Config {
rateLimitMutationMax: process.env.RATE_LIMIT_MUTATION_MAX, rateLimitMutationMax: process.env.RATE_LIMIT_MUTATION_MAX,
rateLimitMutationWindow: process.env.RATE_LIMIT_MUTATION_WINDOW, rateLimitMutationWindow: process.env.RATE_LIMIT_MUTATION_WINDOW,
streamMaxLen: process.env.STREAM_MAXLEN, streamMaxLen: process.env.STREAM_MAXLEN,
baserowTableIds: process.env.BASEROW_TABLE_IDS,
}); });
if (!parsed.success) { if (!parsed.success) {

View file

@ -82,6 +82,25 @@ function parseIntParam(raw: string, label: string): number {
return n; return n;
} }
/**
* Resolve a table param to a numeric Baserow ID. Accepts either a digits-only
* string (e.g. "609") or a slug (e.g. "personne") that maps via the
* BASEROW_TABLE_IDS env (parsed in config). Throws a 400 validation error if
* neither resolves.
*/
function resolveTableId(raw: string, container: ReturnType<typeof getContainer>): number {
if (/^\d+$/.test(raw)) {
const n = Number.parseInt(raw, 10);
if (n > 0) return n;
}
const map = container.config.baserowTableIds ?? {};
const id = map[raw.toLowerCase()];
if (typeof id === 'number' && id > 0) return id;
throw errors.validation([
{ message: `tableId must be a positive integer or a known slug (got: ${raw})` },
]);
}
function parseIntQuery(url: URL, name: string, defaultVal: number, max?: number): number { function parseIntQuery(url: URL, name: string, defaultVal: number, max?: number): number {
const raw = url.searchParams.get(name); const raw = url.searchParams.get(name);
if (!raw) return defaultVal; if (!raw) return defaultVal;
@ -97,8 +116,9 @@ function parseIntQuery(url: URL, name: string, defaultVal: number, max?: number)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
viewsRoutes.get('/table/:tableId', requireScope('read:tables'), async (c) => { viewsRoutes.get('/table/:tableId', requireScope('read:tables'), async (c) => {
const tableId = parseIntParam(c.req.param('tableId'), 'tableId'); const container = getContainer();
const { repos, redis } = getContainer(); const tableId = resolveTableId(c.req.param('tableId'), container);
const { repos, redis } = container;
const views = await repos.views.listByTable(tableId, redis); const views = await repos.views.listByTable(tableId, redis);
@ -126,7 +146,7 @@ viewsRoutes.get('/:viewId/data', requireScope('read:tables'), async (c) => {
if (!tableIdRaw) { if (!tableIdRaw) {
throw errors.validation([{ message: 'tableId query param required' }]); throw errors.validation([{ message: 'tableId query param required' }]);
} }
const tableId = parseIntParam(tableIdRaw, 'tableId'); const tableId = resolveTableId(tableIdRaw, getContainer());
const result = await repos.views.getViewData(viewId, tableId, { const result = await repos.views.getViewData(viewId, tableId, {
page, page,