From cdaa53abd914c095946e20bdbea312cd8e402bd9 Mon Sep 17 00:00:00 2001 From: Vitalii Kulyk Date: Fri, 1 May 2026 13:12:02 +0300 Subject: [PATCH 1/2] feat: enhance documentation for connecting AdminForth to GraphQL API with example and detailed steps --- ...king-without-direct-database-connection.md | 611 +++++++++++++++++- 1 file changed, 606 insertions(+), 5 deletions(-) diff --git a/adminforth/documentation/docs/tutorial/09-Advanced/02-working-without-direct-database-connection.md b/adminforth/documentation/docs/tutorial/09-Advanced/02-working-without-direct-database-connection.md index 2be8e4f40..daac8d018 100644 --- a/adminforth/documentation/docs/tutorial/09-Advanced/02-working-without-direct-database-connection.md +++ b/adminforth/documentation/docs/tutorial/09-Advanced/02-working-without-direct-database-connection.md @@ -9,13 +9,614 @@ With this approach, AdminForth never connects to the database and never even kno Why do this? - Your API may enforce additional constraints or validation rules. +- You can precisely log all operations using your own logging or audit systems. +- Your API may contain custom logic, such as distributed workflows or complex data-modification rules. -- You can precisely log all operations using your own logging or audit systems. (The built-in AuditLog tracks data modifications only and does not log read operations.) +## How it works -- Your API may contain custom logic, such as distributed workflows or complex data-modification rules. +AdminForth picks the connector class based on the protocol prefix of the `url` field in `dataSources`. For example, `sqlite://` uses the SQLite connector, `postgresql://` uses the PostgreSQL connector. + +You can register your own connector class under any custom protocol key using the `databaseConnectors` option in the AdminForth config. Then reference it from your datasource with a matching URL prefix. + +## Example: connecting to a GraphQL API + +If you don't have an app yet, create one with the CLI: + +```bash +npx adminforth create-app --app-name myadmin --db "sqlite://.db.sqlite" +cd myadmin +``` + +We will add a GraphQL API for **Apartments** data (fields: `id`, `name`, `price`, `country`, `created_at`) and write a connector that wires up full create, edit, and delete in AdminForth — without AdminForth ever touching your database directly. + +### Step 1: Install dependencies + +```bash +pnpm add graphql graphql-request @prisma/adapter-better-sqlite3 +pnpm add -D prisma @types/better-sqlite3 +``` + +- `graphql` + `graphql-request` — GraphQL server and client. +- `@prisma/adapter-better-sqlite3` — Prisma v7 driver adapter for SQLite. + +### Step 2: Add the Apartment model to your Prisma schema + +```prisma title="./schema.prisma" +model Apartment { + id String @id + name String + price Float + country String + created_at DateTime @default(now()) +} +``` + + +Generate and run the migration, then regenerate the Prisma client: + +```bash +pnpm makemigration --name add_apartment_table && pnpm migrate:local && pnpm prisma generate +``` + +### Step 3: Add a GraphQL endpoint to your server + +Add this to your existing `api.ts`. Prisma handles the database — AdminForth never touches it directly. `created_at` is set by the database automatically on insert, so the `createApartment` mutation does not accept it. + +```ts title="./api.ts" +import { buildSchema, graphql as gqlExecute } from 'graphql'; +import { randomUUID } from 'crypto'; +import { PrismaClient } from '@prisma/client'; +import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; + +// Prisma v7 requires an explicit driver adapter. +const adapter = new PrismaBetterSqlite3({ url: process.env.PRISMA_DATABASE_URL! }); +const prisma = new PrismaClient({ adapter }); + +const schema = buildSchema(` + type Apartment { + id: ID! + name: String! + price: Float! + country: String! + created_at: String! + } + type Query { + apartments: [Apartment!]! + } + type Mutation { + createApartment(name: String!, price: Float!, country: String!): Apartment! + updateApartment(id: ID!, name: String, price: Float, country: String): Apartment! + deleteApartment(id: ID!): Boolean! + } +`); + +// Prisma returns created_at as a Date — serialize to ISO string for GraphQL. +const serialize = (row: any) => ({ ...row, created_at: row.created_at.toISOString() }); + +const rootValue = { + apartments: async () => { + const rows = await prisma.apartment.findMany({ orderBy: { created_at: 'desc' } }); + return rows.map(serialize); + }, + createApartment: async ({ name, price, country }: any) => { + const row = await prisma.apartment.create({ data: { id: randomUUID(), name, price, country } }); + return serialize(row); + }, + updateApartment: async ({ id, name, price, country }: any) => { + const row = await prisma.apartment.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(price !== undefined && { price }), + ...(country !== undefined && { country }), + }, + }); + return serialize(row); + }, + deleteApartment: async ({ id }: any) => { + try { + await prisma.apartment.delete({ where: { id } }); + return true; + } catch { + return false; + } + }, +}; + +export function initApi(app: Express, admin: IAdminForth) { + // Must be registered BEFORE admin.express.serve(app). + app.post('/graphql', async (req, res) => { + const { query, variables } = req.body; + const result = await gqlExecute({ schema, source: query, rootValue, variableValues: variables }); + res.json({ data: result.data, errors: result.errors }); + }); + + // ...rest of your routes +} +``` + +:::warning Register the route before `admin.express.serve(app)` +`admin.express.serve(app)` catches all unmatched routes. If your `app.post('/graphql', ...)` is registered after it, Express never reaches it and returns 404. + +```ts title="./index.ts" +// ✅ correct order +initApi(app, admin); // registers /graphql first +admin.express.serve(app); // catches everything else + +// ❌ wrong order +admin.express.serve(app); // catches /graphql before it's registered +initApi(app, admin); +``` +::: + +### Step 4: Create the connector + +Create `./datasources/apartmentsConnector.ts`: + +```ts title="./datasources/apartmentsConnector.ts" +import { AdminForthBaseConnector, AdminForthDataTypes, AdminForthFilterOperators } from 'adminforth'; +import type { + AdminForthResource, + AdminForthResourceColumn, + IAdminForthAndOrFilter, + IAdminForthSort, + AdminForthConfig, +} from 'adminforth'; +import { GraphQLClient, gql } from 'graphql-request'; + +// A list page triggers getData + getCount + getMinMaxForColumns — all within +// milliseconds of each other. Cache the raw fetch so all three share one call. +const FETCH_CACHE_TTL_MS = 500; + +export default class ApartmentsGraphqlConnector extends AdminForthBaseConnector { + private gqlClient!: GraphQLClient; + private _cache: { records: any[]; ts: number } | null = null; + + // Called once on startup. `url` is the datasource URL from your config, + // including the custom protocol prefix. Strip it to get the real HTTP(S) URL. + async setupClient(url: string): Promise { + const httpUrl = url + .replace('apartments+http://', 'http://') // local / dev + .replace('apartments://', 'https://'); // production + this.gqlClient = new GraphQLClient(httpUrl); + } + + // Declare which columns exist in the API response and their AdminForth types. + async discoverFields( + _resource: AdminForthResource, + _config: AdminForthConfig, + ): Promise<{ [key: string]: AdminForthResourceColumn }> { + // AdminForthResourceColumn includes fields that AdminForth fills in during + // normalization (e.g. computed showIn shape). We only provide the input-level + // subset here, which is all discoverFields needs. The double cast makes the + // narrowing explicit rather than suppressing it silently with `as any`. + return { + id: { name: 'id', type: AdminForthDataTypes.STRING, primaryKey: true }, + name: { name: 'name', type: AdminForthDataTypes.STRING }, + price: { name: 'price', type: AdminForthDataTypes.FLOAT }, + country: { name: 'country', type: AdminForthDataTypes.STRING }, + created_at: { name: 'created_at', type: AdminForthDataTypes.DATETIME }, + } as unknown as { [key: string]: AdminForthResourceColumn }; + } + + // Convert raw API values to AdminForth types after each read. + // DATETIME fields come back as ISO strings — convert to Date objects. + getFieldValue(field: AdminForthResourceColumn, value: any): any { + if (field.type === AdminForthDataTypes.DATETIME && value) { + return new Date(value); + } + return value; + } + + // Convert AdminForth values back to the format the API accepts before each write. + setFieldValue(field: AdminForthResourceColumn, value: any): any { + if (field.type === AdminForthDataTypes.DATETIME && value instanceof Date) { + return value.toISOString(); + } + return value; + } + + // Client-side filter helper — used because the API returns all records at once. + // Replace with server-side filtering variables if your API supports it. + private applyFilters(records: any[], filters: IAdminForthAndOrFilter): any[] { + if (!filters?.subFilters?.length) return records; + return records.filter((record) => + filters.subFilters.every((f: any) => { + if (f.subFilters) return this.applyFilters([record], f).length > 0; + const val = record[f.field]; + switch (f.operator) { + case AdminForthFilterOperators.EQ: return val == f.value; + case AdminForthFilterOperators.NE: return val != f.value; + case AdminForthFilterOperators.GT: return val > f.value; + case AdminForthFilterOperators.LT: return val < f.value; + case AdminForthFilterOperators.GTE: return val >= f.value; + case AdminForthFilterOperators.LTE: return val <= f.value; + case AdminForthFilterOperators.LIKE: + case AdminForthFilterOperators.ILIKE: + return String(val ?? '').toLowerCase().includes(String(f.value).toLowerCase()); + case AdminForthFilterOperators.IN: return f.value.includes(val); + case AdminForthFilterOperators.NIN: return !f.value.includes(val); + case AdminForthFilterOperators.IS_EMPTY: return val == null || val === ''; + case AdminForthFilterOperators.IS_NOT_EMPTY: return val != null && val !== ''; + default: return true; + } + }) + ); + } + + // Fetches all records from the API. Results are cached for FETCH_CACHE_TTL_MS + // so that getDataWithOriginalTypes, getCount, and getMinMaxForColumns all share + // a single network call per page load. + private async fetchAll(): Promise { + const now = Date.now(); + if (this._cache && now - this._cache.ts < FETCH_CACHE_TTL_MS) { + return this._cache.records; + } + const query = gql` + query { + apartments { id name price country created_at } + } + `; + try { + const data: any = await this.gqlClient.request(query); + this._cache = { records: data.apartments, ts: Date.now() }; + return this._cache.records; + } catch (err: any) { + throw new Error(`ApartmentsGraphqlConnector: failed to fetch apartments — ${err.message}`); + } + } + + // Fetch all records and apply filter/sort/pagination in memory. + // If your API supports server-side filtering, pass the filter tree as query variables instead. + async getDataWithOriginalTypes({ + resource: _resource, + limit, + offset, + sort, + filters, + }: { + resource: AdminForthResource; + limit: number; + offset: number; + sort: IAdminForthSort[]; + filters: IAdminForthAndOrFilter; + }): Promise { + let records = await this.fetchAll(); + records = this.applyFilters(records, filters); + + if (sort?.length) { + const { field, direction } = sort[0]; + records = records.sort((a, b) => { + if (a[field] < b[field]) return direction === 'asc' ? -1 : 1; + if (a[field] > b[field]) return direction === 'asc' ? 1 : -1; + return 0; + }); + } + + return records.slice(offset, offset + limit); + } + + async getCount({ + resource: _resource, + filters, + }: { + resource: AdminForthResource; + filters: IAdminForthAndOrFilter; + }): Promise { + const records = await this.fetchAll(); + return this.applyFilters(records, filters).length; + } + + async getMinMaxForColumnsWithOriginalTypes({ + resource: _resource, + columns, + }: { + resource: AdminForthResource; + columns: AdminForthResourceColumn[]; + }): Promise<{ [key: string]: { min: any; max: any } }> { + const records = await this.fetchAll(); + const result: any = {}; + for (const col of columns) { + const vals = records.map((r) => r[col.name]).filter((v) => v != null); + result[col.name] = { + min: vals.length ? vals.reduce((a: any, b: any) => (a < b ? a : b)) : null, + max: vals.length ? vals.reduce((a: any, b: any) => (a > b ? a : b)) : null, + }; + } + return result; + } + + // POST to the API. Return the primary key of the created record. + // created_at is omitted — the server sets it automatically. + async createRecordOriginalValues({ + resource: _resource, + record, + }: { + resource: AdminForthResource; + record: any; + }): Promise { + const mutation = gql` + mutation CreateApartment($name: String!, $price: Float!, $country: String!) { + createApartment(name: $name, price: $price, country: $country) { + id + } + } + `; + try { + const data: any = await this.gqlClient.request(mutation, { + name: record.name, + price: record.price, + country: record.country, + }); + return data.createApartment.id; + } catch (err: any) { + throw new Error(`ApartmentsGraphqlConnector: createApartment failed — ${err.message}`); + } + } + + // PATCH to the API with only the changed fields. + async updateRecordOriginalValues({ + resource: _resource, + recordId, + newValues, + }: { + resource: AdminForthResource; + recordId: string; + newValues: any; + }): Promise { + const mutation = gql` + mutation UpdateApartment($id: ID!, $name: String, $price: Float, $country: String) { + updateApartment(id: $id, name: $name, price: $price, country: $country) { + id + } + } + `; + try { + await this.gqlClient.request(mutation, { + id: recordId, + name: newValues.name, + price: newValues.price, + country: newValues.country, + }); + } catch (err: any) { + throw new Error(`ApartmentsGraphqlConnector: updateApartment failed — ${err.message}`); + } + } + + // DELETE via the API. Return true on success. + async deleteRecord({ + resource: _resource, + recordId, + }: { + resource: AdminForthResource; + recordId: string; + }): Promise { + const mutation = gql` + mutation DeleteApartment($id: ID!) { + deleteApartment(id: $id) + } + `; + try { + const data: any = await this.gqlClient.request(mutation, { id: recordId }); + return data.deleteApartment; + } catch (err: any) { + throw new Error(`ApartmentsGraphqlConnector: deleteApartment failed — ${err.message}`); + } + } + + // These two methods are used during AdminForth schema discovery. Return values + // must match what discoverFields returns — same table name and same column names. + async getAllTables(): Promise { + return ['apartments']; + } + + async getAllColumnsInTable( + _tableName: string, + ): Promise> { + return [ + { name: 'id', isPrimaryKey: true }, + { name: 'name' }, + { name: 'price' }, + { name: 'country' }, + { name: 'created_at' }, + ]; + } +} +``` + +### Step 5: Register the connector and add the resource + +Create `./resources/apartments.ts`: + +```ts title="./resources/apartments.ts" +import type { AdminForthResource } from 'adminforth'; + +export default { + dataSource: 'apartments', + table: 'apartments', + resourceId: 'apartments', + label: 'Apartments', + columns: [ + { + name: 'id', + primaryKey: true, + showIn: { list: false, show: true, create: false, edit: false, filter: false }, + }, + { name: 'name', label: 'Name', required: { create: true, edit: true } }, + { name: 'price', label: 'Price ($/mo)', required: { create: true, edit: true } }, + { name: 'country', label: 'Country', required: { create: true, edit: true } }, + { + name: 'created_at', + label: 'Created At', + // Hide on create/edit — the server sets this field automatically. + showIn: { list: true, show: true, filter: true, create: false, edit: false }, + }, + ], +} as AdminForthResource; +``` + +Then wire it up in `index.ts`: + +```ts title="./index.ts" +//diff-add +import ApartmentsGraphqlConnector from './datasources/apartmentsConnector.js'; +//diff-add +import apartmentsResource from './resources/apartments.js'; + +export const admin = new AdminForth({ + // ...existing config... + +//diff-add + databaseConnectors: { +//diff-add + 'apartments+http': ApartmentsGraphqlConnector, +//diff-add + }, + + dataSources: [ + { id: 'maindb', url: `${process.env.DATABASE_URL}` }, +//diff-add + { +//diff-add + id: 'apartments', +//diff-add + url: 'apartments+http://localhost:3500/graphql', +//diff-add + }, + ], + + resources: [ + usersResource, +//diff-add + apartmentsResource, + ], + + menu: [ + // ...existing menu... +//diff-add + { +//diff-add + label: 'Apartments', +//diff-add + icon: 'flowbite:home-solid', +//diff-add + resourceId: 'apartments', +//diff-add + }, + ], +}); +``` + +### Step 6: Run + +```bash +pnpm start +``` + +Open [http://localhost:3500](http://localhost:3500). The **Apartments** section is backed entirely by your GraphQL API — AdminForth never connects to the database directly. + +## Adapting to REST + +The same connector pattern works for REST APIs. The key differences are: + +- In `setupClient`: create an axios or fetch client with the base URL and any auth headers. +- In `getDataWithOriginalTypes`: `GET /apartments?limit=X&offset=Y`. +- In `getCount`: `GET /apartments/count` or read a `total` field from the list response. +- In `createRecordOriginalValues`: `POST /apartments`, return `response.id`. +- In `updateRecordOriginalValues`: `PATCH /apartments/:id`. +- In `deleteRecord`: `DELETE /apartments/:id`, return `true`. + +Everything else (filter application, field type mapping, AdminForth integration) stays the same. + +## Connector API reference + +This section describes every method of `AdminForthBaseConnector` — which ones you must implement, which are provided for free, and how they relate to each other. + +### Methods you must implement + +These methods throw `Error('Method not implemented.')` in the base class. Your connector must override all of them. + +#### `setupClient(url: string): Promise` + +Called once during AdminForth initialization. `url` is the value from `dataSources[].url` in your config — including the custom protocol prefix. Strip the prefix and create your API client here. + +#### `discoverFields(resource, config): Promise<{ [colName: string]: AdminForthResourceColumn }>` + +Called during schema discovery. Return a map of column name → column definition. Each entry needs at minimum `name` and `type` (`AdminForthDataTypes.*`). Mark the primary key column with `primaryKey: true`. + +#### `getFieldValue(field, value): any` + +Called after every read, once per cell. Convert raw API values to the types AdminForth expects. For example: ISO string → `Date` for `DATETIME` fields. + +#### `setFieldValue(field, value): any` + +Called before every write, once per cell. Convert AdminForth values back to what your API accepts. The inverse of `getFieldValue`. + +#### `getDataWithOriginalTypes({ resource, limit, offset, sort, filters }): Promise` + +Main read method. Fetch records, apply pagination/sort/filters, return raw API records. Do **not** convert types here — `getFieldValue` handles that afterward. + +#### `getCount({ resource, filters }): Promise` + +Return the total number of records matching `filters`. Used for pagination. + +#### `getMinMaxForColumnsWithOriginalTypes({ resource, columns }): Promise<{ [colName: string]: { min, max } }>` + +Return min/max raw values for each column. Used for range filter UI (sliders, date pickers). + +#### `createRecordOriginalValues({ resource, record }): Promise` + +Create a record. `record` values are already converted through `setFieldValue`. Return the new record's primary key. + +#### `updateRecordOriginalValues({ resource, recordId, newValues }): Promise` + +Update a record. `newValues` contains only changed fields, already converted through `setFieldValue`. + +#### `deleteRecord({ resource, recordId }): Promise` + +Delete a record. Return `true` on success. + +#### `getAllTables(): Promise` + +Return the logical table names your connector exposes. Used during schema discovery. + +#### `getAllColumnsInTable(tableName): Promise>` + +Return column metadata for a table. Used during schema discovery. + +--- + +### Methods provided by the base class + +| Method | What it does | +|---|---| +| `getData(...)` | Validates filters, calls `getDataWithOriginalTypes` + `getCount` in parallel, applies `getFieldValue` per cell. | +| `createRecord(...)` | Runs `fillOnCreate` hooks, `validateAndSetFieldValue`, uniqueness checks, then calls your `createRecordOriginalValues`. | +| `updateRecord(...)` | Runs `validateAndSetFieldValue`, uniqueness checks, calls your `updateRecordOriginalValues`, publishes live-update. | +| `getRecordByPrimaryKey(...)` | Fetches one record by PK, applies `getFieldValue`. | +| `validateAndSetFieldValue(field, value)` | Type-validates the value, then calls your `setFieldValue`. | + +### Data flow + +``` +List page + └─ getData() [base] + ├─ getDataWithOriginalTypes() [YOU] ← raw records + ├─ getCount() [YOU] + └─ getFieldValue() per cell [YOU] ← type conversion + +Create form submit + └─ createRecord() [base] + ├─ validateAndSetFieldValue() [base → calls YOUR setFieldValue] + ├─ createRecordOriginalValues() [YOU] ← mutation + └─ getRecordByPrimaryKey() [base] ← fetch back -To implement this, you need to extend the data connector class and implement a small set of methods responsible for data access and mutations. +Edit form submit + └─ updateRecord() [base] + ├─ validateAndSetFieldValue() [base → calls YOUR setFieldValue] + └─ updateRecordOriginalValues() [YOU] ← mutation +``` -This example demonstrates how to do this using GraphQL, but the same approach can be adapted to REST or any other protocol. The code comments include detailed guidance for these cases. +### Optional: aggregation support -Another reason to create a custom data source adapter is to support a database that AdminForth does not yet support. In that case, you are welcome to submit a pull request to AdminForth to add native support for that database. \ No newline at end of file +Override `getAggregateWithOriginalTypes` if you want dashboard charts and the `.aggregate()` Data API to work with your connector. Not required for list/show/create/edit/delete. From b7f3a3df69bb7fe3a77a5ef83d3468ea27d4d674 Mon Sep 17 00:00:00 2001 From: Vitalii Kulyk Date: Tue, 12 May 2026 15:38:11 +0300 Subject: [PATCH 2/2] feat: add meta field to AdminForthResourceInputCommon for custom connector configurations --- ...king-without-direct-database-connection.md | 654 ++++++++++-------- adminforth/types/Common.ts | 9 +- 2 files changed, 390 insertions(+), 273 deletions(-) diff --git a/adminforth/documentation/docs/tutorial/09-Advanced/02-working-without-direct-database-connection.md b/adminforth/documentation/docs/tutorial/09-Advanced/02-working-without-direct-database-connection.md index daac8d018..78c131fa2 100644 --- a/adminforth/documentation/docs/tutorial/09-Advanced/02-working-without-direct-database-connection.md +++ b/adminforth/documentation/docs/tutorial/09-Advanced/02-working-without-direct-database-connection.md @@ -20,140 +20,39 @@ You can register your own connector class under any custom protocol key using th ## Example: connecting to a GraphQL API -If you don't have an app yet, create one with the CLI: +This example uses **two completely separate applications**: -```bash -npx adminforth create-app --app-name myadmin --db "sqlite://.db.sqlite" -cd myadmin -``` +| App | Role | Port | +|---|---|---| +| `my-api` | GraphQL backend — owns the database, exposes CRUD over HTTP | `3001` | +| `myadmin` | AdminForth admin panel — never touches the DB, calls the API | `3500` | -We will add a GraphQL API for **Apartments** data (fields: `id`, `name`, `price`, `country`, `created_at`) and write a connector that wires up full create, edit, and delete in AdminForth — without AdminForth ever touching your database directly. +AdminForth never sees the database URL. All reads and writes go through the GraphQL API. -### Step 1: Install dependencies +### Part 1 — The backend API (`my-api`) -```bash -pnpm add graphql graphql-request @prisma/adapter-better-sqlite3 -pnpm add -D prisma @types/better-sqlite3 -``` - -- `graphql` + `graphql-request` — GraphQL server and client. -- `@prisma/adapter-better-sqlite3` — Prisma v7 driver adapter for SQLite. +The full backend source is available as a separate repository. Follow the setup instructions there to get a GraphQL API running on `http://localhost:3001` before continuing. -### Step 2: Add the Apartment model to your Prisma schema +> **Backend example repository:** [devforth/adminforth-graphql-api-example](https://github.com/devforth/adminforth-graphql-api-example) -```prisma title="./schema.prisma" -model Apartment { - id String @id - name String - price Float - country String - created_at DateTime @default(now()) -} -``` +Once the API is running, continue with Part 2 below. +### Part 2 — The AdminForth app (`myadmin`) -Generate and run the migration, then regenerate the Prisma client: +If you don't have an AdminForth app yet, create one: ```bash -pnpm makemigration --name add_apartment_table && pnpm migrate:local && pnpm prisma generate -``` - -### Step 3: Add a GraphQL endpoint to your server - -Add this to your existing `api.ts`. Prisma handles the database — AdminForth never touches it directly. `created_at` is set by the database automatically on insert, so the `createApartment` mutation does not accept it. - -```ts title="./api.ts" -import { buildSchema, graphql as gqlExecute } from 'graphql'; -import { randomUUID } from 'crypto'; -import { PrismaClient } from '@prisma/client'; -import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; - -// Prisma v7 requires an explicit driver adapter. -const adapter = new PrismaBetterSqlite3({ url: process.env.PRISMA_DATABASE_URL! }); -const prisma = new PrismaClient({ adapter }); - -const schema = buildSchema(` - type Apartment { - id: ID! - name: String! - price: Float! - country: String! - created_at: String! - } - type Query { - apartments: [Apartment!]! - } - type Mutation { - createApartment(name: String!, price: Float!, country: String!): Apartment! - updateApartment(id: ID!, name: String, price: Float, country: String): Apartment! - deleteApartment(id: ID!): Boolean! - } -`); - -// Prisma returns created_at as a Date — serialize to ISO string for GraphQL. -const serialize = (row: any) => ({ ...row, created_at: row.created_at.toISOString() }); - -const rootValue = { - apartments: async () => { - const rows = await prisma.apartment.findMany({ orderBy: { created_at: 'desc' } }); - return rows.map(serialize); - }, - createApartment: async ({ name, price, country }: any) => { - const row = await prisma.apartment.create({ data: { id: randomUUID(), name, price, country } }); - return serialize(row); - }, - updateApartment: async ({ id, name, price, country }: any) => { - const row = await prisma.apartment.update({ - where: { id }, - data: { - ...(name !== undefined && { name }), - ...(price !== undefined && { price }), - ...(country !== undefined && { country }), - }, - }); - return serialize(row); - }, - deleteApartment: async ({ id }: any) => { - try { - await prisma.apartment.delete({ where: { id } }); - return true; - } catch { - return false; - } - }, -}; - -export function initApi(app: Express, admin: IAdminForth) { - // Must be registered BEFORE admin.express.serve(app). - app.post('/graphql', async (req, res) => { - const { query, variables } = req.body; - const result = await gqlExecute({ schema, source: query, rootValue, variableValues: variables }); - res.json({ data: result.data, errors: result.errors }); - }); - - // ...rest of your routes -} +npx adminforth create-app --app-name myadmin --db "sqlite://.db.sqlite" +cd myadmin ``` -:::warning Register the route before `admin.express.serve(app)` -`admin.express.serve(app)` catches all unmatched routes. If your `app.post('/graphql', ...)` is registered after it, Express never reaches it and returns 404. +The `--db` flag is only used to scaffold the project. In the steps below you will replace the local database with the GraphQL API, so `myadmin` ends up with no direct database connection at all. -```ts title="./index.ts" -// ✅ correct order -initApi(app, admin); // registers /graphql first -admin.express.serve(app); // catches everything else +#### Step 1: Create the connector -// ❌ wrong order -admin.express.serve(app); // catches /graphql before it's registered -initApi(app, admin); -``` -::: +Create `./datasources/graphqlConnector.ts`. This is a fully generic connector — it reads each resource's API config from `options.meta` at startup, so you never need to edit this file when adding new entities: -### Step 4: Create the connector - -Create `./datasources/apartmentsConnector.ts`: - -```ts title="./datasources/apartmentsConnector.ts" +```ts title="./datasources/graphqlConnector.ts" import { AdminForthBaseConnector, AdminForthDataTypes, AdminForthFilterOperators } from 'adminforth'; import type { AdminForthResource, @@ -162,62 +61,154 @@ import type { IAdminForthSort, AdminForthConfig, } from 'adminforth'; -import { GraphQLClient, gql } from 'graphql-request'; -// A list page triggers getData + getCount + getMinMaxForColumns — all within -// milliseconds of each other. Cache the raw fetch so all three share one call. +// A list page fires getData + getCount + getMinMaxForColumns within milliseconds. +// Cache the raw fetch so all three share one network call. const FETCH_CACHE_TTL_MS = 500; -export default class ApartmentsGraphqlConnector extends AdminForthBaseConnector { - private gqlClient!: GraphQLClient; - private _cache: { records: any[]; ts: number } | null = null; +// Minimal gql tag — provides syntax highlighting only, no transformation. +export const gql = (strings: TemplateStringsArray, ...values: any[]) => + strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), ''); + +export interface GraphqlApiDef { + /** Name of the primary key column, e.g. `'id'`. */ + primaryKey: string; + /** Root GraphQL query field that returns an array, e.g. `'apartments'`. */ + queryName: string; + /** Space-separated field names to select, e.g. `'id name price country created_at'`. */ + selection: string; + mutations?: { + create?: { + /** Full GQL mutation document string. */ + gql: string; + /** Extract the mutation's input variables from the AdminForth record. */ + variables: (record: any) => Record; + /** Root field on the result that contains `{ id }`, e.g. `'createApartment'`. */ + resultField: string; + }; + update?: { + gql: string; + /** Extract variables from (recordId, changedFields). */ + variables: (id: string, newValues: any) => Record; + }; + delete?: { + gql: string; + /** Root field on the result that holds the success boolean, e.g. `'deleteApartment'`. */ + resultField: string; + }; + }; +} + +/** + * Generic AdminForth connector for any GraphQL API. + * + * Each resource provides its API config in `options.meta`. + * Register this class under a custom protocol key in `databaseConnectors`. + */ +export default class GraphqlConnector extends AdminForthBaseConnector { + private gqlEndpoint!: string; + + // Per-table record cache keyed by table name. + private _cache: Map = new Map(); - // Called once on startup. `url` is the datasource URL from your config, - // including the custom protocol prefix. Strip it to get the real HTTP(S) URL. + // API configs populated from resource.options.meta during discoverFields. + private _apiConfigs: Map = new Map(); + + // ── Lifecycle ──────────────────────────────────────────────────────────────── + + /** + * Strip the custom protocol prefix so the URL becomes a real HTTP(S) URL: + * graphql+http://127.0.0.1:3500/graphql → http://127.0.0.1:3500/graphql + * graphql://api.example.com/graphql → https://api.example.com/graphql + */ async setupClient(url: string): Promise { - const httpUrl = url - .replace('apartments+http://', 'http://') // local / dev - .replace('apartments://', 'https://'); // production - this.gqlClient = new GraphQLClient(httpUrl); + this.gqlEndpoint = url.includes('+http://') + ? 'http://' + url.split('+http://')[1] + : 'https://' + url.split('://').slice(1).join('://'); + } + + // ── Internal HTTP ───────────────────────────────────────────────────────────── + + private async gqlRequest(query: string, variables?: Record): Promise { + const res = await fetch(this.gqlEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status} from ${this.gqlEndpoint}`); + const json: any = await res.json(); + if (json.errors?.length) throw new Error(json.errors[0]?.message ?? 'GraphQL error'); + return json.data; + } + + // ── Schema discovery ───────────────────────────────────────────────────────── + + async getAllTables(): Promise { + return []; + } + + async getAllColumnsInTable( + tableName: string, + ): Promise> { + const def = this._apiConfigs.get(tableName); + if (!def) return [{ name: 'id', isPrimaryKey: true }]; + return def.selection.trim().split(/\s+/).map((name) => ({ + name, + isPrimaryKey: name === def.primaryKey, + })); } - // Declare which columns exist in the API response and their AdminForth types. async discoverFields( - _resource: AdminForthResource, + resource: AdminForthResource, _config: AdminForthConfig, ): Promise<{ [key: string]: AdminForthResourceColumn }> { - // AdminForthResourceColumn includes fields that AdminForth fills in during - // normalization (e.g. computed showIn shape). We only provide the input-level - // subset here, which is all discoverFields needs. The double cast makes the - // narrowing explicit rather than suppressing it silently with `as any`. - return { - id: { name: 'id', type: AdminForthDataTypes.STRING, primaryKey: true }, - name: { name: 'name', type: AdminForthDataTypes.STRING }, - price: { name: 'price', type: AdminForthDataTypes.FLOAT }, - country: { name: 'country', type: AdminForthDataTypes.STRING }, - created_at: { name: 'created_at', type: AdminForthDataTypes.DATETIME }, - } as unknown as { [key: string]: AdminForthResourceColumn }; + const apiConfig: GraphqlApiDef | undefined = (resource.options as any)?.meta; + if (!apiConfig) { + throw new Error( + `GraphqlConnector: resource '${resource.resourceId}' is missing options.meta. ` + + `Add { primaryKey, queryName, selection, mutations } to options.meta.` + ); + } + this._apiConfigs.set(resource.table, apiConfig); + + const result: { [key: string]: AdminForthResourceColumn } = {}; + for (const name of apiConfig.selection.trim().split(/\s+/)) { + result[name] = { name, type: AdminForthDataTypes.STRING, primaryKey: name === apiConfig.primaryKey }; + } + return result; } - // Convert raw API values to AdminForth types after each read. - // DATETIME fields come back as ISO strings — convert to Date objects. + // ── Type conversion (called per-cell by the base class) ────────────────────── + getFieldValue(field: AdminForthResourceColumn, value: any): any { - if (field.type === AdminForthDataTypes.DATETIME && value) { - return new Date(value); - } + if (field.type === AdminForthDataTypes.DATETIME && value) return new Date(value); return value; } - // Convert AdminForth values back to the format the API accepts before each write. setFieldValue(field: AdminForthResourceColumn, value: any): any { - if (field.type === AdminForthDataTypes.DATETIME && value instanceof Date) { - return value.toISOString(); - } + if (field.type === AdminForthDataTypes.DATETIME && value instanceof Date) return value.toISOString(); return value; } - // Client-side filter helper — used because the API returns all records at once. - // Replace with server-side filtering variables if your API supports it. + // ── Read ───────────────────────────────────────────────────────────────────── + + private async fetchAll(tableName: string): Promise { + const def = this.apiDef(tableName); + const now = Date.now(); + const cached = this._cache.get(tableName); + if (cached && now - cached.ts < FETCH_CACHE_TTL_MS) return cached.records; + + const query = `query { ${def.queryName} { ${def.selection} } }`; + try { + const data = await this.gqlRequest(query); + const records = data[def.queryName]; + this._cache.set(tableName, { records, ts: Date.now() }); + return records; + } catch (err: any) { + throw new Error(`GraphqlConnector[${tableName}]: fetch failed — ${err.message}`); + } + } + private applyFilters(records: any[], filters: IAdminForthAndOrFilter): any[] { if (!filters?.subFilters?.length) return records; return records.filter((record) => @@ -244,36 +235,8 @@ export default class ApartmentsGraphqlConnector extends AdminForthBaseConnector ); } - // Fetches all records from the API. Results are cached for FETCH_CACHE_TTL_MS - // so that getDataWithOriginalTypes, getCount, and getMinMaxForColumns all share - // a single network call per page load. - private async fetchAll(): Promise { - const now = Date.now(); - if (this._cache && now - this._cache.ts < FETCH_CACHE_TTL_MS) { - return this._cache.records; - } - const query = gql` - query { - apartments { id name price country created_at } - } - `; - try { - const data: any = await this.gqlClient.request(query); - this._cache = { records: data.apartments, ts: Date.now() }; - return this._cache.records; - } catch (err: any) { - throw new Error(`ApartmentsGraphqlConnector: failed to fetch apartments — ${err.message}`); - } - } - - // Fetch all records and apply filter/sort/pagination in memory. - // If your API supports server-side filtering, pass the filter tree as query variables instead. async getDataWithOriginalTypes({ - resource: _resource, - limit, - offset, - sort, - filters, + resource, limit, offset, sort, filters, }: { resource: AdminForthResource; limit: number; @@ -281,9 +244,8 @@ export default class ApartmentsGraphqlConnector extends AdminForthBaseConnector sort: IAdminForthSort[]; filters: IAdminForthAndOrFilter; }): Promise { - let records = await this.fetchAll(); + let records = await this.fetchAll(resource.table); records = this.applyFilters(records, filters); - if (sort?.length) { const { field, direction } = sort[0]; records = records.sort((a, b) => { @@ -292,29 +254,26 @@ export default class ApartmentsGraphqlConnector extends AdminForthBaseConnector return 0; }); } - return records.slice(offset, offset + limit); } async getCount({ - resource: _resource, - filters, + resource, filters, }: { resource: AdminForthResource; filters: IAdminForthAndOrFilter; }): Promise { - const records = await this.fetchAll(); + const records = await this.fetchAll(resource.table); return this.applyFilters(records, filters).length; } async getMinMaxForColumnsWithOriginalTypes({ - resource: _resource, - columns, + resource, columns, }: { resource: AdminForthResource; columns: AdminForthResourceColumn[]; }): Promise<{ [key: string]: { min: any; max: any } }> { - const records = await this.fetchAll(); + const records = await this.fetchAll(resource.table); const result: any = {}; for (const col of columns) { const vals = records.map((r) => r[col.name]).filter((v) => v != null); @@ -326,142 +285,283 @@ export default class ApartmentsGraphqlConnector extends AdminForthBaseConnector return result; } - // POST to the API. Return the primary key of the created record. - // created_at is omitted — the server sets it automatically. + // ── Write ──────────────────────────────────────────────────────────────────── + async createRecordOriginalValues({ - resource: _resource, - record, + resource, record, }: { resource: AdminForthResource; record: any; }): Promise { - const mutation = gql` - mutation CreateApartment($name: String!, $price: Float!, $country: String!) { - createApartment(name: $name, price: $price, country: $country) { - id - } - } - `; + const mut = this.apiDef(resource.table).mutations?.create; + if (!mut) throw new Error(`GraphqlConnector[${resource.table}]: no create mutation defined`); try { - const data: any = await this.gqlClient.request(mutation, { - name: record.name, - price: record.price, - country: record.country, - }); - return data.createApartment.id; + const data = await this.gqlRequest(mut.gql, mut.variables(record)); + this._cache.delete(resource.table); + return data[mut.resultField].id; } catch (err: any) { - throw new Error(`ApartmentsGraphqlConnector: createApartment failed — ${err.message}`); + throw new Error(`GraphqlConnector[${resource.table}]: create failed — ${err.message}`); } } - // PATCH to the API with only the changed fields. async updateRecordOriginalValues({ - resource: _resource, - recordId, - newValues, + resource, recordId, newValues, }: { resource: AdminForthResource; recordId: string; newValues: any; }): Promise { - const mutation = gql` - mutation UpdateApartment($id: ID!, $name: String, $price: Float, $country: String) { - updateApartment(id: $id, name: $name, price: $price, country: $country) { - id - } - } - `; + const mut = this.apiDef(resource.table).mutations?.update; + if (!mut) throw new Error(`GraphqlConnector[${resource.table}]: no update mutation defined`); try { - await this.gqlClient.request(mutation, { - id: recordId, - name: newValues.name, - price: newValues.price, - country: newValues.country, - }); + await this.gqlRequest(mut.gql, mut.variables(recordId, newValues)); + this._cache.delete(resource.table); } catch (err: any) { - throw new Error(`ApartmentsGraphqlConnector: updateApartment failed — ${err.message}`); + throw new Error(`GraphqlConnector[${resource.table}]: update failed — ${err.message}`); } } - // DELETE via the API. Return true on success. async deleteRecord({ - resource: _resource, - recordId, + resource, recordId, }: { resource: AdminForthResource; recordId: string; }): Promise { - const mutation = gql` - mutation DeleteApartment($id: ID!) { - deleteApartment(id: $id) - } - `; + const mut = this.apiDef(resource.table).mutations?.delete; + if (!mut) throw new Error(`GraphqlConnector[${resource.table}]: no delete mutation defined`); try { - const data: any = await this.gqlClient.request(mutation, { id: recordId }); - return data.deleteApartment; + const data = await this.gqlRequest(mut.gql, { id: recordId }); + this._cache.delete(resource.table); + return data[mut.resultField]; } catch (err: any) { - throw new Error(`ApartmentsGraphqlConnector: deleteApartment failed — ${err.message}`); + throw new Error(`GraphqlConnector[${resource.table}]: delete failed — ${err.message}`); } } - // These two methods are used during AdminForth schema discovery. Return values - // must match what discoverFields returns — same table name and same column names. - async getAllTables(): Promise { - return ['apartments']; - } + // ── Internal ───────────────────────────────────────────────────────────────── - async getAllColumnsInTable( - _tableName: string, - ): Promise> { - return [ - { name: 'id', isPrimaryKey: true }, - { name: 'name' }, - { name: 'price' }, - { name: 'country' }, - { name: 'created_at' }, - ]; + private apiDef(tableName: string): GraphqlApiDef { + const def = this._apiConfigs.get(tableName); + if (!def) throw new Error( + `GraphqlConnector: no config for '${tableName}'. Ensure options.meta is set on the resource.` + ); + return def; } } ``` -### Step 5: Register the connector and add the resource +#### Step 2: Create the resource files + +Each resource declares its GraphQL queries and mutations in `options.meta`. The connector reads this config during startup — no entity-specific code lives in the connector itself. Create `./resources/apartments.ts`: ```ts title="./resources/apartments.ts" -import type { AdminForthResource } from 'adminforth'; +import { AdminForthDataTypes } from 'adminforth'; +import type { AdminForthResourceInput } from 'adminforth'; +import { gql } from '../datasources/graphqlConnector.js'; export default { - dataSource: 'apartments', + dataSource: 'myApi', table: 'apartments', resourceId: 'apartments', label: 'Apartments', + options: { + meta: { + primaryKey: 'id', + queryName: 'apartments', + selection: 'id name price country created_at', + mutations: { + create: { + gql: gql` + mutation CreateApartment($name: String!, $price: Float!, $country: String!) { + createApartment(name: $name, price: $price, country: $country) { id } + } + `, + variables: (r: any) => ({ name: r.name, price: r.price, country: r.country }), + resultField: 'createApartment', + }, + update: { + gql: gql` + mutation UpdateApartment($id: ID!, $name: String, $price: Float, $country: String) { + updateApartment(id: $id, name: $name, price: $price, country: $country) { id } + } + `, + variables: (id: string, v: any) => ({ id, name: v.name, price: v.price, country: v.country }), + }, + delete: { + gql: gql` + mutation DeleteApartment($id: ID!) { deleteApartment(id: $id) } + `, + resultField: 'deleteApartment', + }, + }, + }, + }, columns: [ { name: 'id', + type: AdminForthDataTypes.STRING, primaryKey: true, showIn: { list: false, show: true, create: false, edit: false, filter: false }, }, - { name: 'name', label: 'Name', required: { create: true, edit: true } }, - { name: 'price', label: 'Price ($/mo)', required: { create: true, edit: true } }, - { name: 'country', label: 'Country', required: { create: true, edit: true } }, + { name: 'name', type: AdminForthDataTypes.STRING, label: 'Name', required: { create: true, edit: true } }, + { name: 'price', type: AdminForthDataTypes.FLOAT, label: 'Price ($/mo)', required: { create: true, edit: true } }, + { name: 'country', type: AdminForthDataTypes.STRING, label: 'Country', required: { create: true, edit: true } }, { name: 'created_at', + type: AdminForthDataTypes.DATETIME, label: 'Created At', - // Hide on create/edit — the server sets this field automatically. showIn: { list: true, show: true, filter: true, create: false, edit: false }, }, ], -} as AdminForthResource; +} as AdminForthResourceInput; ``` -Then wire it up in `index.ts`: +:::tip Types are required +The connector defaults every column to `STRING`. Set `type` explicitly on columns that need numeric (`FLOAT`, `INTEGER`) or date (`DATETIME`) handling — otherwise AdminForth won't coerce values before sending them to your API. +::: + +The generated `usersResource` points to the original local database. Replace it with a full resource that points to `myApi`. Create `./resources/adminuser.ts`: + +```ts title="./resources/adminuser.ts" +import AdminForth, { AdminForthDataTypes } from 'adminforth'; +import type { AdminForthResourceInput, AdminForthResource, AdminUser } from 'adminforth'; +import { randomUUID } from 'crypto'; +import { logger } from 'adminforth'; +import { gql } from '../datasources/graphqlConnector.js'; + +async function allowedForSuperAdmin({ adminUser }: { adminUser: AdminUser }): Promise { + return adminUser.dbUser.role === 'superadmin'; +} + +export default { + dataSource: 'myApi', + table: 'adminuser', + resourceId: 'adminuser', + label: 'Admin Users', + recordLabel: (r) => `👤 ${r.email}`, + options: { + allowedActions: { + edit: allowedForSuperAdmin, + delete: allowedForSuperAdmin, + }, + meta: { + primaryKey: 'id', + queryName: 'adminUsers', + selection: 'id email password_hash role created_at', + mutations: { + create: { + gql: gql` + mutation CreateAdminUser($id: ID!, $email: String!, $password_hash: String!, $role: String!) { + createAdminUser(id: $id, email: $email, password_hash: $password_hash, role: $role) { id } + } + `, + variables: (r: any) => ({ id: r.id, email: r.email, password_hash: r.password_hash, role: r.role }), + resultField: 'createAdminUser', + }, + update: { + gql: gql` + mutation UpdateAdminUser($id: ID!, $email: String, $password_hash: String, $role: String) { + updateAdminUser(id: $id, email: $email, password_hash: $password_hash, role: $role) { id } + } + `, + variables: (id: string, v: any) => ({ id, email: v.email, password_hash: v.password_hash, role: v.role }), + }, + delete: { + gql: gql` + mutation DeleteAdminUser($id: ID!) { deleteAdminUser(id: $id) } + `, + resultField: 'deleteAdminUser', + }, + }, + }, + }, + columns: [ + { + name: 'id', + primaryKey: true, + type: AdminForthDataTypes.STRING, + fillOnCreate: ({ initialRecord, adminUser }) => randomUUID(), + showIn: { edit: false, create: false }, + }, + { + name: 'email', + required: true, + isUnique: true, + type: AdminForthDataTypes.STRING, + validation: [ + { + regExp: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$', + message: 'Email is not valid, must be in format example@test.com' + }, + ] + }, + { + name: 'created_at', + type: AdminForthDataTypes.DATETIME, + showIn: { edit: false, create: false }, + fillOnCreate: ({ initialRecord, adminUser }) => (new Date()).toISOString(), + }, + { + name: 'role', + type: AdminForthDataTypes.STRING, + enum: [ + { value: 'superadmin', label: 'Super Admin' }, + { value: 'user', label: 'User' }, + ] + }, + { + name: 'password', + virtual: true, + required: { create: true }, + editingNote: { edit: 'Leave empty to keep password unchanged' }, + type: AdminForthDataTypes.STRING, + showIn: { show: false, list: false, filter: false }, + masked: true, + minLength: 8, + validation: [AdminForth.Utils.PASSWORD_VALIDATORS.UP_LOW_NUM], + }, + { + name: 'password_hash', + type: AdminForthDataTypes.STRING, + backendOnly: true, + showIn: { all: false } + } + ], + hooks: { + create: { + beforeSave: async ({ record, adminUser, resource }: { record: any, adminUser: AdminUser, resource: AdminForthResource }) => { + record.password_hash = await AdminForth.Utils.generatePasswordHash(record.password); + return { ok: true }; + } + }, + edit: { + beforeSave: async ({ oldRecord, updates, adminUser, resource }: { oldRecord: any, updates: any, adminUser: AdminUser, resource: AdminForthResource }) => { + if (oldRecord.id === adminUser.dbUser.id && updates.role) { + return { ok: false, error: 'You cannot change your own role' }; + } + if (updates.password) { + updates.password_hash = await AdminForth.Utils.generatePasswordHash(updates.password); + } + return { ok: true } + }, + }, + }, +} as AdminForthResourceInput; +``` + +#### Step 3: Wire everything up in `index.ts` + +Update `index.ts` — register the connector, replace the local datasource with `myApi`, and add the resources: ```ts title="./index.ts" //diff-add -import ApartmentsGraphqlConnector from './datasources/apartmentsConnector.js'; +import GraphqlConnector from './datasources/graphqlConnector.js'; //diff-add import apartmentsResource from './resources/apartments.js'; +//diff-add +import usersResource from './resources/adminuser.js'; export const admin = new AdminForth({ // ...existing config... @@ -469,18 +569,21 @@ export const admin = new AdminForth({ //diff-add databaseConnectors: { //diff-add - 'apartments+http': ApartmentsGraphqlConnector, + 'graphql+http': GraphqlConnector, // graphql+http:// → http:// (dev) +//diff-add + 'graphql': GraphqlConnector, // graphql:// → https:// (prod) //diff-add }, dataSources: [ +//diff-remove { id: 'maindb', url: `${process.env.DATABASE_URL}` }, //diff-add { //diff-add - id: 'apartments', + id: 'myApi', //diff-add - url: 'apartments+http://localhost:3500/graphql', + url: 'graphql+http://localhost:3001/graphql', //diff-add }, ], @@ -507,13 +610,20 @@ export const admin = new AdminForth({ }); ``` -### Step 6: Run +With this setup, every resource — including admin users — is backed by the GraphQL API. The `myadmin` app has no database connection of its own. + +#### Step 4: Run both services ```bash -pnpm start +# Terminal 1 — backend API +cd my-api && npx tsx index.ts + +# Terminal 2 — AdminForth +cd myadmin && pnpm start ``` -Open [http://localhost:3500](http://localhost:3500). The **Apartments** section is backed entirely by your GraphQL API — AdminForth never connects to the database directly. +Open [http://localhost:3500](http://localhost:3500). Both **Users** and **Apartments** are backed entirely by your GraphQL API — `myadmin` never connects to a database directly. + ## Adapting to REST @@ -542,7 +652,7 @@ Called once during AdminForth initialization. `url` is the value from `dataSourc #### `discoverFields(resource, config): Promise<{ [colName: string]: AdminForthResourceColumn }>` -Called during schema discovery. Return a map of column name → column definition. Each entry needs at minimum `name` and `type` (`AdminForthDataTypes.*`). Mark the primary key column with `primaryKey: true`. +Called during schema discovery. Return a map of column name → base column definition. The `GraphqlConnector` implementation reads `resource.options.meta` to get the field list and defaults every type to `STRING`; the resource's `columns` config then overrides types, labels, and display options on top. #### `getFieldValue(field, value): any` diff --git a/adminforth/types/Common.ts b/adminforth/types/Common.ts index 7d2b8e7af..f277ea0a5 100644 --- a/adminforth/types/Common.ts +++ b/adminforth/types/Common.ts @@ -514,7 +514,14 @@ export interface AdminForthResourceInputCommon { /** * Whether to refresh existing list rows automatically every N seconds. */ - listRowsAutoRefreshSeconds?: number, + listRowsAutoRefreshSeconds?: number, + + /** + * Custom metadata for the resource. Can be used by custom connectors to store + * entity-specific configuration (e.g. API queries, mutations, endpoints). + * AdminForth itself does not use this field — it is passed through to the connector. + */ + meta?: any, /** * Custom components which can be injected into AdminForth CRUD pages.