Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/agent-bff/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@forestadmin/datasource-toolkit": "1.55.1",
"@forestadmin/forestadmin-client": "1.43.2",
"@koa/bodyparser": "^6.1.0",
"inflected": "^1.1.6",
"jsonwebtoken": "^9.0.3",
"koa": "^3.0.1",
"zod": "4.3.6"
Expand All @@ -46,6 +47,7 @@
"@forestadmin/agent-testing": "1.2.7",
"@hey-api/openapi-ts": "0.99.0",
"@redocly/cli": "2.35.1",
"@types/inflected": "^1.1.29",
"@types/jsonwebtoken": "^9.0.1",
"@types/koa": "^2.13.5",
"@types/supertest": "^6.0.2",
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bff/src/openapi/collect-unfolding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ async function collectFields(
}

return {
projectable: capabilities.fields.map(field => field.name),
projectable: capabilities.fields.map(({ name, type }) => ({ name, type })),
filterable: collectFilterableFields(collection, capabilities, logger),
degraded: null,
};
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-bff/src/openapi/names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@ export default function createNamer(): Namer {
return candidate;
};
}

/** A customer name inside prose. Quoted rather than bare: a name can carry spaces or punctuation. */
export function quoted(name: string): string {
return JSON.stringify(name);
}
153 changes: 153 additions & 0 deletions packages/agent-bff/src/openapi/record-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { ProjectableField, UnfoldedCollection } from './unfolding';
import type { ReferenceObject, SchemaObject } from 'openapi3-ts/oas31';

import Inflector from 'inflected';

import toFieldSchema from './field-schemas';
import { quoted } from './names';
import { PACKED_ID_SEPARATOR } from '../data/pack-id';

/**
* The key a field really carries in a response record. `agent-client` deserializes the agent's
* JSON:API with `keyForAttribute: 'camelCase'` (`http-requester.ts`), which is exactly this pair of
* `inflected` calls (`jsonapi-serializer/lib/inflector.js`), so a `first_name` column is PROJECTED
* under that name and RETURNED as `firstName`. The same library rather than a transcription: the
* transform handles acronyms and non-ASCII, and a mirror would drift from the deserializer without
* anything failing.
*/
export function recordKey(field: string): string {
return Inflector.camelize(Inflector.underscore(field), false);
}

// The flat id is the JSON:API resource id, which is a string by specification whatever the key
// column holds. `__forest.primaryKey` is the same id unpacked and typed, so the two forms of one
// record disagree by construction — the trap this schema exists to name.
const ID_SCHEMA: SchemaObject = {
type: 'string',
description:
'The record id, always a string — the agent serializes it as the JSON:API resource id, even ' +
'when the key column is a Number. `__forest.primaryKey` carries the same id TYPED, so ' +
`comparing the two without coercion fails. A composite key is its values joined by ` +
`${quoted(PACKED_ID_SEPARATOR)}.`,
};

function isReference(schema: SchemaObject | ReferenceObject): schema is ReferenceObject {
return '$ref' in schema;
}

/**
* Every published field is nullable. The capabilities report a column type and never its
* nullability, so a nullable column answers `null` against a type this schema would otherwise
* declare non-null — a generated client validating the response would reject what the runtime
* really sends. An unconstrained schema already accepts null and is left alone.
*/
function nullable(schema: SchemaObject): SchemaObject {
if (typeof schema.type !== 'string') return schema;
Comment on lines +41 to +45

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nullable() stops at the container, so items and a nested object's properties stay non-null — the same failure mode one level down, while the record prose says a null is always possible whatever the type says. Recurse, or narrow the prose to the top level?


const widened: SchemaObject = { ...schema, type: [schema.type, 'null'] };

if (schema.items !== undefined && !isReference(schema.items)) {
widened.items = nullable(schema.items);
}

if (schema.properties !== undefined) {
widened.properties = Object.fromEntries(
Object.entries(schema.properties).map(([key, nested]) => [
key,
isReference(nested) ? nested : nullable(nested),
]),
);
}

return widened;
}

/**
* The schema of one field, under the key the response carries it as. Several fields can collapse to
* the same key — the transform is lossy, `first_name` and `firstName` both yield `firstName` — and
* which one wins depends on the order the agent serialized them in, so the type is left open rather
* than picked.
*/
function propertySchema(key: string, fields: ProjectableField[]): SchemaObject {
const names = fields.map(field => quoted(field.name)).join(', ');

if (fields.length > 1) {
return {
description:
`${names} all reach the response under this single key, so which one it holds is not ` +
'determined here. Project one of them at a time to know.',
};
}
Comment thread
Tonours marked this conversation as resolved.

const [field] = fields;
const schema = nullable(toFieldSchema(field.type));

if (field.name === key) return schema;

const description = [schema.description, `The ${names} field.`].filter(Boolean).join(' ');

return { ...schema, description };
}

function fieldProperties(projectable: ProjectableField[]): Record<string, SchemaObject> {
const byKey = new Map<string, ProjectableField[]>();

projectable.forEach(field => {
const key = recordKey(field.name);
const collapsed = byKey.get(key);

if (collapsed) collapsed.push(field);
else byKey.set(key, [field]);
});

return Object.fromEntries([...byKey].map(([key, fields]) => [key, propertySchema(key, fields)]));
}

/**
* The record shape of one collection. The properties are what the capabilities report, and nothing
* more is forbidden: the capabilities route leaves out a OneToOne relation that the default
* projection returns, so a closed schema would reject responses the runtime really sends. `id` wins
* over any field of that name, because the deserializer overwrites the attribute with the resource
* id.
*/
export default function recordSchema(
collection: UnfoldedCollection,
forestMeta: ReferenceObject,
): SchemaObject {
return {
type: 'object',
description:
`A record of ${quoted(collection.name)}. It carries the fields the request projected — ` +
'omit `projection` and the agent returns them all. The properties below are the ones the ' +
"collection's capabilities report; a record can carry more, such as a to-one relation. " +
'Every field is nullable: the capabilities report a column type and never its nullability, ' +
'so a null is always possible whatever the type says.',
properties: {
...fieldProperties(collection.fields.projectable),
id: ID_SCHEMA,
__forest: forestMeta,
},
required: ['id', '__forest'],
};
}

export function listResponseSchema(
collection: UnfoldedCollection,
record: ReferenceObject,
): SchemaObject {
return {
type: 'object',
description:
`A page of ${quoted(collection.name)} records. The list never carries a total: call the ` +
'count endpoint for that, which is why `countStatus` is always `not_requested`.',
properties: {
data: { type: 'array', items: record },
meta: {
type: 'object',
properties: { countStatus: { type: 'string', const: 'not_requested' } },
required: ['countStatus'],
},
},
required: ['data', 'meta'],
};
}
14 changes: 10 additions & 4 deletions packages/agent-bff/src/openapi/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { allOperators } from '@forestadmin/datasource-toolkit';

import { z } from './zod-openapi';
import { PACKED_ID_SEPARATOR } from '../data/pack-id';
import {
PageInput,
ParentIdInput,
Expand Down Expand Up @@ -140,15 +141,17 @@ export const ActionRequestSchema = z
'targets no record. Every id is coerced to a string before reaching the agent.',
});

const ForestRecordMetaSchema = z
export const ForestRecordMetaSchema = z
.object({
collection: z.string(),
primaryKey: z.record(z.string(), z.union([z.string(), z.number()])),
})
.openapi('ForestRecordMeta', {
description:
'The record identity, unpacked from the agent id. A composite primary key carries one ' +
'entry per column.',
'entry per column. The values are TYPED here — a Number key column is a number — whereas ' +
'the record carries the same id as a string under `id`, so comparing the two forms ' +
'without coercion fails.',
});

export const ListResponseSchema = z
Expand All @@ -160,8 +163,11 @@ export const ListResponseSchema = z
})
.openapi('ListResponse', {
description:
'Records are flat, each carrying a `__forest` envelope. The list never carries a total: ' +
'call the count endpoint for that, which is why `countStatus` is always `not_requested`.',
'Records are flat, each carrying a `__forest` envelope. A record always holds `id`, the ' +
`agent id as a string — a composite key is its values joined by \`${PACKED_ID_SEPARATOR}\` — ` +
'while `__forest.primaryKey` holds that same id typed and split per column. The list never ' +
'carries a total: call the count endpoint for that, which is why `countStatus` is always ' +
'`not_requested`.',
});

export const CountResponseSchema = z
Expand Down
52 changes: 41 additions & 11 deletions packages/agent-bff/src/openapi/unfolded-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import type { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import type { ReferenceObject, SchemaObject } from 'openapi3-ts/oas31';

import toFieldSchema from './field-schemas';
import createNamer from './names';
import createNamer, { quoted } from './names';
import recordSchema, { listResponseSchema } from './record-schemas';
import {
CountResponseSchema,
ForestRecordMetaSchema,
ListResponseSchema,
OPERATORS,
PageSchema,
Expand Down Expand Up @@ -48,6 +50,8 @@ interface CollectionPlan {
collection: UnfoldedCollection;
key: string;
requests: RequestRefs;
/** Carried on the plan so a relation list answers with the FOREIGN collection's own shape. */
response: ReferenceObject;
}

interface Deps {
Expand All @@ -59,10 +63,6 @@ interface Deps {
errorResponses: (executeResults: boolean) => Record<string, ReferenceObject>;
}

function quoted(name: string): string {
return JSON.stringify(name);
}

// A name reaches the runtime through decodeURIComponent, so the document must carry it encoded —
// including the separators that would otherwise change the route, such as a slash inside an action
// name (`a/b` is reachable only as `a%2Fb`).
Expand Down Expand Up @@ -228,7 +228,7 @@ function fieldRefs(deps: Deps, plan: Pick<CollectionPlan, 'key' | 'collection'>)
const projectable = fieldsEnum(
pool,
`Fields_${plan.key}`,
fields.projectable,
fields.projectable.map(field => field.name),
`A field of ${quoted(name)}.`,
);

Expand Down Expand Up @@ -260,7 +260,10 @@ function requestDescription(collection: UnfoldedCollection, subject: string): st
return `${subject}${note}`;
}

function registerRequests(deps: Deps, plan: Omit<CollectionPlan, 'requests'>): RequestRefs {
function registerRequests(
deps: Deps,
plan: Pick<CollectionPlan, 'collection' | 'key'>,
): RequestRefs {
const { pool } = deps;
const { collection, key } = plan;
const refs = fieldRefs(deps, plan);
Expand Down Expand Up @@ -292,6 +295,29 @@ function registerRequests(deps: Deps, plan: Omit<CollectionPlan, 'requests'>): R
};
}

/**
* A collection whose field set could not be read keeps the shared untyped response: a record schema
* built from an empty projectable set would claim the record holds nothing but `id` and `__forest`.
*/
function registerListResponse(
deps: Deps,
plan: Pick<CollectionPlan, 'collection' | 'key'>,
): ReferenceObject {
const { pool } = deps;
const { collection, key } = plan;

if (collection.fields.projectable.length === 0) {
return pool.reuse('ListResponse', ListResponseSchema);
}

const record = pool.add(
`Record_${key}`,
recordSchema(collection, pool.reuse('ForestRecordMeta', ForestRecordMetaSchema)),
);

return pool.add(`ListResponse_${key}`, listResponseSchema(collection, record));
}

const PARENT_ID_SHAPE = {
anyOf: [{ type: 'string' as const, pattern: '\\S' }, { type: 'number' as const }],
};
Expand Down Expand Up @@ -460,7 +486,6 @@ function registerOperation(deps: Deps, options: OperationOptions): void {
function registerCollectionOperations(deps: Deps, plan: CollectionPlan): void {
const { pool } = deps;
const { name } = plan.collection;
const listResponse = pool.reuse('ListResponse', ListResponseSchema);
const countResponse = pool.reuse('CountResponse', CountResponseSchema);

registerOperation(deps, {
Expand All @@ -470,7 +495,7 @@ function registerCollectionOperations(deps: Deps, plan: CollectionPlan): void {
summary: `List records of ${name}`,
description: `Lists records of the ${quoted(name)} collection.`,
request: plan.requests.list,
response: listResponse,
response: plan.response,
responseDescription: `A page of ${quoted(name)} records`,
bodyRequired: false,
});
Expand Down Expand Up @@ -513,7 +538,7 @@ function registerRelationOperations(
plan.collection.name,
)} record through ${parent}.`,
request: requests.list,
response: pool.reuse('ListResponse', ListResponseSchema),
response: foreign.response,
responseDescription: `A page of related ${quoted(foreign.collection.name)} records`,
bodyRequired: true,
});
Expand Down Expand Up @@ -586,7 +611,12 @@ export default function registerUnfoldedPaths(deps: Deps, unfolding: Unfolding):
const plans = unfolding.collections.map(collection => {
const key = collections(collection.name);

return { collection, key, requests: registerRequests(deps, { collection, key }) };
return {
collection,
key,
requests: registerRequests(deps, { collection, key }),
response: registerListResponse(deps, { collection, key }),
};
});
const plansByName = new Map(plans.map(plan => [plan.collection.name, plan]));

Expand Down
11 changes: 10 additions & 1 deletion packages/agent-bff/src/openapi/unfolding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,20 @@ export interface UnfoldedRelation {
* their field schemas stay free-form strings — an empty enum would forbid every valid call.
*/
export interface CollectionFields {
projectable: string[];
projectable: ProjectableField[];
filterable: FilterableField[];
degraded: DegradedReason | null;
}

/**
* A field the agent reports, with the column type it declares. The type is what turns the response
* records into a typed schema; the name is what the request enums carry.
*/
export interface ProjectableField {
name: string;
type: FieldType;
}

/**
* A field the agent reports at least one operator for, with that operator set — the very set the
* runtime validates a filter leaf against. Normalized to canonical PascalCase and to the
Expand Down
Loading
Loading