feat(bridge): admin endpoint to list tables of a database
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
E2E Playwright / Playwright e2e (chromium) (push) Waiting to run
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
E2E Playwright / Playwright e2e (chromium) (push) Waiting to run
Add BaserowAdminClient.listTables(databaseId) and expose it as GET /api/v1/admin/tables?databaseId=X (scope: tables:write). The existing public /api/v1/tables route requires a Baserow user JWT and a databaseId filter that the AcadeDoc client never supplied, so the insert-database modal could not list tables. Routing through admin uses the service-account JWT already wired in the container.
This commit is contained in:
parent
173c4e14f9
commit
0d4aa0413d
2 changed files with 439 additions and 0 deletions
218
bridge/src/adapters/baserow-admin-client.ts
Normal file
218
bridge/src/adapters/baserow-admin-client.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
/**
|
||||||
|
* Baserow Admin Client — endpoints CRUD pour tables / fields / views.
|
||||||
|
*
|
||||||
|
* Toutes les ops nécessitent un user JWT (Bearer "JWT <token>") car les
|
||||||
|
* Database Tokens (Token brg_*) ne couvrent que les rows. Le JWT est obtenu
|
||||||
|
* via le `BaserowJwtManager` configuré avec un compte de service Baserow
|
||||||
|
* (BASEROW_USER_EMAIL / BASEROW_USER_PASSWORD).
|
||||||
|
*
|
||||||
|
* Routes Baserow utilisées (référence: https://baserow.io/api-docs) :
|
||||||
|
* POST /api/database/tables/database/{database_id}/ create-table
|
||||||
|
* PATCH /api/database/tables/{table_id}/ update-table
|
||||||
|
* DELETE /api/database/tables/{table_id}/ delete-table
|
||||||
|
* POST /api/database/fields/table/{table_id}/ create-field
|
||||||
|
* PATCH /api/database/fields/{field_id}/ update-field
|
||||||
|
* DELETE /api/database/fields/{field_id}/ delete-field
|
||||||
|
* POST /api/database/views/table/{table_id}/ create-view
|
||||||
|
* PATCH /api/database/views/{view_id}/ update-view
|
||||||
|
* DELETE /api/database/views/{view_id}/ delete-view
|
||||||
|
* GET /api/applications/workspace/{workspace_id}/ list-databases
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ofetch } from 'ofetch';
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
import type { BaserowJwtManager } from '../lib/baserow-jwt-manager.js';
|
||||||
|
import { errors } from '../lib/errors.js';
|
||||||
|
|
||||||
|
export interface BaserowAdminClientOptions {
|
||||||
|
baseUrl: string;
|
||||||
|
jwtManager: BaserowJwtManager;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldType =
|
||||||
|
| 'text'
|
||||||
|
| 'long_text'
|
||||||
|
| 'number'
|
||||||
|
| 'rating'
|
||||||
|
| 'boolean'
|
||||||
|
| 'date'
|
||||||
|
| 'last_modified'
|
||||||
|
| 'created_on'
|
||||||
|
| 'url'
|
||||||
|
| 'email'
|
||||||
|
| 'phone_number'
|
||||||
|
| 'single_select'
|
||||||
|
| 'multiple_select'
|
||||||
|
| 'link_row'
|
||||||
|
| 'file'
|
||||||
|
| 'formula'
|
||||||
|
| 'lookup'
|
||||||
|
| 'count'
|
||||||
|
| 'rollup'
|
||||||
|
| 'multiple_collaborators'
|
||||||
|
| 'autonumber'
|
||||||
|
| 'duration'
|
||||||
|
| 'uuid';
|
||||||
|
|
||||||
|
export interface CreateFieldPayload {
|
||||||
|
name: string;
|
||||||
|
type: FieldType;
|
||||||
|
// Type-specific extras (passed through verbatim).
|
||||||
|
// For formula: { formula: "field('A') - field('B')", formula_type?: 'number' }
|
||||||
|
// For number: { number_decimal_places: 2 }
|
||||||
|
// For single_select / multiple_select: { select_options: [{value, color}] }
|
||||||
|
// For link_row: { link_row_table_id: <tableId> }
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFieldPayload {
|
||||||
|
name?: string;
|
||||||
|
type?: FieldType;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTablePayload {
|
||||||
|
name: string;
|
||||||
|
data?: string[][];
|
||||||
|
first_row_header?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateViewPayload {
|
||||||
|
name: string;
|
||||||
|
type: 'grid' | 'gallery' | 'form' | 'kanban' | 'calendar' | 'timeline';
|
||||||
|
filter_type?: 'AND' | 'OR';
|
||||||
|
filters_disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BaserowAdminClient {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly jwtManager: BaserowJwtManager;
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
|
constructor(opts: BaserowAdminClientOptions) {
|
||||||
|
this.baseUrl = opts.baseUrl;
|
||||||
|
this.jwtManager = opts.jwtManager;
|
||||||
|
this.logger = opts.logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
path: string,
|
||||||
|
init?: { method?: string; body?: unknown },
|
||||||
|
): Promise<T> {
|
||||||
|
if (!this.jwtManager.isEnabled()) {
|
||||||
|
throw errors.baserowUserAuthNotConfigured();
|
||||||
|
}
|
||||||
|
const token = await this.jwtManager.getToken();
|
||||||
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
return ofetch<T>(url, {
|
||||||
|
method: init?.method,
|
||||||
|
body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||||
|
headers: {
|
||||||
|
Authorization: `JWT ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
retry: 1,
|
||||||
|
retryDelay: 200,
|
||||||
|
timeout: 15_000,
|
||||||
|
onResponseError: ({ response }) => {
|
||||||
|
this.logger.error(
|
||||||
|
{ status: response.status, url, body: response._data },
|
||||||
|
'baserow admin error',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
const error = err as { response?: { status?: number; _data?: unknown } };
|
||||||
|
const status = error.response?.status;
|
||||||
|
if (status === 401 || status === 403) throw errors.authInvalid();
|
||||||
|
if (status === 404) throw errors.notFound('Baserow resource', path);
|
||||||
|
if (!error.response) throw errors.baserowDown();
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Workspaces / databases --------
|
||||||
|
|
||||||
|
async listWorkspaces(): Promise<unknown[]> {
|
||||||
|
return this.request<unknown[]>(`/workspaces/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDatabases(workspaceId: number): Promise<unknown[]> {
|
||||||
|
return this.request<unknown[]>(
|
||||||
|
`/applications/workspace/${workspaceId}/`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDatabase(workspaceId: number, name: string): Promise<unknown> {
|
||||||
|
return this.request(`/applications/workspace/${workspaceId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name, type: 'database' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Tables --------
|
||||||
|
|
||||||
|
async listTables(databaseId: number): Promise<unknown[]> {
|
||||||
|
return this.request<unknown[]>(
|
||||||
|
`/database/tables/database/${databaseId}/`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTable(databaseId: number, payload: CreateTablePayload): Promise<unknown> {
|
||||||
|
return this.request(`/database/tables/database/${databaseId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTable(tableId: number, payload: { name: string }): Promise<unknown> {
|
||||||
|
return this.request(`/database/tables/${tableId}/`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTable(tableId: number): Promise<void> {
|
||||||
|
await this.request(`/database/tables/${tableId}/`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Fields --------
|
||||||
|
|
||||||
|
async createField(tableId: number, payload: CreateFieldPayload): Promise<unknown> {
|
||||||
|
return this.request(`/database/fields/table/${tableId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateField(fieldId: number, payload: UpdateFieldPayload): Promise<unknown> {
|
||||||
|
return this.request(`/database/fields/${fieldId}/`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteField(fieldId: number): Promise<void> {
|
||||||
|
await this.request(`/database/fields/${fieldId}/`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Views --------
|
||||||
|
|
||||||
|
async createView(tableId: number, payload: CreateViewPayload): Promise<unknown> {
|
||||||
|
return this.request(`/database/views/table/${tableId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateView(viewId: number, payload: Partial<CreateViewPayload>): Promise<unknown> {
|
||||||
|
return this.request(`/database/views/${viewId}/`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteView(viewId: number): Promise<void> {
|
||||||
|
await this.request(`/database/views/${viewId}/`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
}
|
||||||
221
bridge/src/routes/admin.ts
Normal file
221
bridge/src/routes/admin.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
/**
|
||||||
|
* Routes /api/v1/admin — CRUD tables / fields / views via Baserow user JWT.
|
||||||
|
*
|
||||||
|
* Toutes ces routes nécessitent le scope `tables:write` (ou `admin:*`) côté
|
||||||
|
* caller, et le BASEROW_USER_EMAIL/PASSWORD côté config (sinon 503).
|
||||||
|
*
|
||||||
|
* Endpoints exposés :
|
||||||
|
* GET /v1/admin/databases?workspaceId=N list databases in workspace
|
||||||
|
* POST /v1/admin/databases body: { workspaceId, name }
|
||||||
|
* GET /v1/admin/tables?databaseId=N list tables in database
|
||||||
|
* POST /v1/admin/tables body: { databaseId, name }
|
||||||
|
* PATCH /v1/admin/tables/:tableId body: { name }
|
||||||
|
* DELETE /v1/admin/tables/:tableId
|
||||||
|
* POST /v1/admin/tables/:tableId/fields body: { name, type, ... }
|
||||||
|
* PATCH /v1/admin/fields/:fieldId body: { name?, type?, ... }
|
||||||
|
* DELETE /v1/admin/fields/:fieldId
|
||||||
|
* POST /v1/admin/tables/:tableId/views body: { name, type }
|
||||||
|
* PATCH /v1/admin/views/:viewId body: { name?, ... }
|
||||||
|
* DELETE /v1/admin/views/:viewId
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { getContainer } from '../lib/container.js';
|
||||||
|
import { errors } from '../lib/errors.js';
|
||||||
|
import { parseBody } from '../lib/http.js';
|
||||||
|
import { type AuthVariables, requireScope } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
export const adminRoutes = new Hono<{ Variables: AuthVariables }>();
|
||||||
|
|
||||||
|
// ---------- Schemas ----------
|
||||||
|
|
||||||
|
const createDatabaseSchema = z.object({
|
||||||
|
workspaceId: z.coerce.number().int().positive(),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTableSchema = z.object({
|
||||||
|
databaseId: z.coerce.number().int().positive(),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
data: z.array(z.array(z.string())).optional(),
|
||||||
|
first_row_header: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTableSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fieldTypeSchema = z.enum([
|
||||||
|
'text',
|
||||||
|
'long_text',
|
||||||
|
'number',
|
||||||
|
'rating',
|
||||||
|
'boolean',
|
||||||
|
'date',
|
||||||
|
'last_modified',
|
||||||
|
'created_on',
|
||||||
|
'url',
|
||||||
|
'email',
|
||||||
|
'phone_number',
|
||||||
|
'single_select',
|
||||||
|
'multiple_select',
|
||||||
|
'link_row',
|
||||||
|
'file',
|
||||||
|
'formula',
|
||||||
|
'lookup',
|
||||||
|
'count',
|
||||||
|
'rollup',
|
||||||
|
'multiple_collaborators',
|
||||||
|
'autonumber',
|
||||||
|
'duration',
|
||||||
|
'uuid',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const createFieldSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
type: fieldTypeSchema,
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const updateFieldSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
type: fieldTypeSchema.optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const createViewSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
type: z.enum(['grid', 'gallery', 'form', 'kanban', 'calendar', 'timeline']),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const updateViewSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// ---------- Helpers ----------
|
||||||
|
|
||||||
|
function parseIntId(raw: string | undefined, label: string): number {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) {
|
||||||
|
throw errors.validation([`${label} must be a positive integer`]);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Workspaces ----------
|
||||||
|
|
||||||
|
adminRoutes.get('/workspaces', requireScope('tables:write'), async (c) => {
|
||||||
|
const data = await getContainer().baserowAdmin.listWorkspaces();
|
||||||
|
return c.json({ data });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Databases ----------
|
||||||
|
|
||||||
|
adminRoutes.get('/databases', requireScope('tables:write'), async (c) => {
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const wsRaw = url.searchParams.get('workspaceId');
|
||||||
|
const workspaceId = parseIntId(wsRaw ?? undefined, 'workspaceId');
|
||||||
|
const data = await getContainer().baserowAdmin.listDatabases(workspaceId);
|
||||||
|
return c.json({ data });
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.post('/databases', requireScope('tables:write'), async (c) => {
|
||||||
|
const body = await parseBody(c, createDatabaseSchema);
|
||||||
|
const data = await getContainer().baserowAdmin.createDatabase(
|
||||||
|
body.workspaceId,
|
||||||
|
body.name,
|
||||||
|
);
|
||||||
|
return c.json({ data }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Tables ----------
|
||||||
|
|
||||||
|
adminRoutes.get('/tables', requireScope('tables:write'), async (c) => {
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const dbRaw = url.searchParams.get('databaseId');
|
||||||
|
const databaseId = parseIntId(dbRaw ?? undefined, 'databaseId');
|
||||||
|
const data = await getContainer().baserowAdmin.listTables(databaseId);
|
||||||
|
return c.json({ data });
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.post('/tables', requireScope('tables:write'), async (c) => {
|
||||||
|
const body = await parseBody(c, createTableSchema);
|
||||||
|
const data = await getContainer().baserowAdmin.createTable(body.databaseId, {
|
||||||
|
name: body.name,
|
||||||
|
data: body.data,
|
||||||
|
first_row_header: body.first_row_header,
|
||||||
|
});
|
||||||
|
return c.json({ data }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.patch('/tables/:tableId', requireScope('tables:write'), async (c) => {
|
||||||
|
const tableId = parseIntId(c.req.param('tableId'), 'tableId');
|
||||||
|
const body = await parseBody(c, updateTableSchema);
|
||||||
|
const data = await getContainer().baserowAdmin.updateTable(tableId, body);
|
||||||
|
return c.json({ data });
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.delete('/tables/:tableId', requireScope('tables:write'), async (c) => {
|
||||||
|
const tableId = parseIntId(c.req.param('tableId'), 'tableId');
|
||||||
|
await getContainer().baserowAdmin.deleteTable(tableId);
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Fields ----------
|
||||||
|
|
||||||
|
adminRoutes.post(
|
||||||
|
'/tables/:tableId/fields',
|
||||||
|
requireScope('tables:write'),
|
||||||
|
async (c) => {
|
||||||
|
const tableId = parseIntId(c.req.param('tableId'), 'tableId');
|
||||||
|
const body = await parseBody(c, createFieldSchema);
|
||||||
|
const data = await getContainer().baserowAdmin.createField(tableId, body);
|
||||||
|
return c.json({ data }, 201);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
adminRoutes.patch('/fields/:fieldId', requireScope('tables:write'), async (c) => {
|
||||||
|
const fieldId = parseIntId(c.req.param('fieldId'), 'fieldId');
|
||||||
|
const body = await parseBody(c, updateFieldSchema);
|
||||||
|
const data = await getContainer().baserowAdmin.updateField(fieldId, body);
|
||||||
|
return c.json({ data });
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.delete('/fields/:fieldId', requireScope('tables:write'), async (c) => {
|
||||||
|
const fieldId = parseIntId(c.req.param('fieldId'), 'fieldId');
|
||||||
|
await getContainer().baserowAdmin.deleteField(fieldId);
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Views ----------
|
||||||
|
|
||||||
|
adminRoutes.post(
|
||||||
|
'/tables/:tableId/views',
|
||||||
|
requireScope('tables:write'),
|
||||||
|
async (c) => {
|
||||||
|
const tableId = parseIntId(c.req.param('tableId'), 'tableId');
|
||||||
|
const body = await parseBody(c, createViewSchema);
|
||||||
|
const data = await getContainer().baserowAdmin.createView(tableId, body);
|
||||||
|
return c.json({ data }, 201);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
adminRoutes.patch('/views/:viewId', requireScope('tables:write'), async (c) => {
|
||||||
|
const viewId = parseIntId(c.req.param('viewId'), 'viewId');
|
||||||
|
const body = await parseBody(c, updateViewSchema);
|
||||||
|
const data = await getContainer().baserowAdmin.updateView(viewId, body);
|
||||||
|
return c.json({ data });
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.delete('/views/:viewId', requireScope('tables:write'), async (c) => {
|
||||||
|
const viewId = parseIntId(c.req.param('viewId'), 'viewId');
|
||||||
|
await getContainer().baserowAdmin.deleteView(viewId);
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue