-
Notifications
You must be signed in to change notification settings - Fork 12
feat(agent-bff): serve the agent schema contract on GET /agent/v1/context #1838
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
56df8b1
2486239
0b9973e
22b0408
d675257
b877af9
8d4f1c6
99fdc26
b555b2f
cca0447
fe98f68
6b93f1b
dda9996
f86f185
3e2ec4b
b1d4c50
c917ac3
dd4321c
1720cf4
796473d
6dc75ec
410be8f
b2250eb
90942eb
b70614f
bba8d23
82f7e09
e01fa1f
72f4c41
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| import type { FieldType } from '../read-model/capabilities-cache'; | ||
| import type ReadModel from '../read-model/read-model'; | ||
| import type { RelationshipType } from '../read-model/read-model'; | ||
| import type { | ||
| ForestSchemaAction, | ||
| ForestSchemaCollection, | ||
| ForestSchemaField, | ||
| } from '@forestadmin/forestadmin-client'; | ||
|
|
||
| export interface ContextActionField { | ||
| field: string; | ||
| type: FieldType; | ||
| isRequired?: boolean; | ||
| defaultValue?: unknown; | ||
| enums?: string[]; | ||
| } | ||
|
|
||
| export interface ContextAction { | ||
| id: string; | ||
| name: string; | ||
| type: ForestSchemaAction['type']; | ||
| fields: ContextActionField[]; | ||
| } | ||
|
|
||
| export interface ContextValidation { | ||
| type: string; | ||
| value?: unknown; | ||
| } | ||
|
|
||
| export interface ContextField { | ||
| field: string; | ||
| type: FieldType; | ||
| relationship?: RelationshipType; | ||
| reference?: string; | ||
| inverseOf?: string; | ||
| polymorphicTargets?: string[]; | ||
| isPrimaryKey?: boolean; | ||
| isRequired?: boolean; | ||
| isReadOnly?: boolean; | ||
| enums?: string[]; | ||
| validations?: ContextValidation[]; | ||
| } | ||
|
|
||
| export interface ContextCollection { | ||
| name: string; | ||
| fields: ContextField[]; | ||
| actions: ContextAction[]; | ||
| } | ||
|
|
||
| export interface ContextMeta { | ||
| schemaRevision: number; | ||
| environmentId?: number; | ||
| } | ||
|
|
||
| export interface AgentContext { | ||
| collections: ContextCollection[]; | ||
| meta: ContextMeta; | ||
| } | ||
|
|
||
| function toArray<T>(value: T[] | null | undefined): T[] { | ||
| return Array.isArray(value) ? value : []; | ||
| } | ||
|
|
||
| type FieldWithWireEnums = ForestSchemaField & { enums?: string[] }; | ||
|
|
||
| function toContextValidations(validations: unknown[] | null | undefined): ContextValidation[] { | ||
| return toArray(validations) | ||
| .filter( | ||
| (entry): entry is { type: string; value?: unknown } => | ||
| typeof entry === 'object' && | ||
| entry !== null && | ||
| typeof (entry as { type?: unknown }).type === 'string', | ||
| ) | ||
| .map(entry => | ||
| 'value' in entry ? { type: entry.type, value: entry.value } : { type: entry.type }, | ||
| ); | ||
| } | ||
|
|
||
| function toContextField(field: FieldWithWireEnums): ContextField { | ||
| const serialized: ContextField = { field: field.field, type: field.type }; | ||
|
|
||
| if (field.relationship) serialized.relationship = field.relationship; | ||
| if (field.reference) serialized.reference = field.reference; | ||
| if (field.inverseOf) serialized.inverseOf = field.inverseOf; | ||
|
Tonours marked this conversation as resolved.
|
||
|
|
||
| const polymorphicTargets = toArray(field.polymorphicReferencedModels); | ||
| if (polymorphicTargets.length > 0) serialized.polymorphicTargets = [...polymorphicTargets]; | ||
|
Comment on lines
+83
to
+87
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Real gap — the fixture already exhibits it: it references The two cases are not symmetric. Filtering would also undo your round-1 point. Strip So the contract now states it: the description says a target named by
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No answer on the contract-change question, so I am leaving the behaviour as it stands and treating the point as settled by documentation plus a test. The document states the target may be absent from If you would rather the document never name an unserved target, that is a contract change — reopen this and I will open the ticket. |
||
|
|
||
| if (field.isPrimaryKey) serialized.isPrimaryKey = true; | ||
| if (field.isRequired) serialized.isRequired = true; | ||
| if (field.isReadOnly) serialized.isReadOnly = true; | ||
|
|
||
| const enums = toArray(field.enums); | ||
| if (enums.length > 0) serialized.enums = [...enums]; | ||
|
|
||
| const validations = toContextValidations(field.validations); | ||
| if (validations.length > 0) serialized.validations = validations; | ||
|
|
||
| return serialized; | ||
| } | ||
|
|
||
| function toContextActionField(field: ForestSchemaAction['fields'][number]): ContextActionField { | ||
| const serialized: ContextActionField = { field: field.field, type: field.type }; | ||
|
|
||
| if (field.isRequired !== undefined) serialized.isRequired = field.isRequired; | ||
| if (field.defaultValue !== undefined) serialized.defaultValue = field.defaultValue; | ||
| if (Array.isArray(field.enums)) serialized.enums = [...field.enums]; | ||
|
|
||
| return serialized; | ||
| } | ||
|
|
||
| function toContextAction(action: ForestSchemaAction): ContextAction { | ||
| return { | ||
| id: action.id, | ||
| name: action.name, | ||
| type: action.type, | ||
| fields: toArray(action.fields) | ||
| .filter(field => typeof field === 'object' && field !== null) | ||
| .map(toContextActionField), | ||
| }; | ||
| } | ||
|
|
||
| function toContextCollection( | ||
| collection: ForestSchemaCollection, | ||
| readModel: ReadModel, | ||
| ): ContextCollection { | ||
| return { | ||
| name: collection.name, | ||
| fields: toArray(collection.fields).map(toContextField), | ||
| actions: toArray(collection.actions) | ||
| .filter(action => readModel.isActionAllowed(collection.name, action.name)) | ||
| .map(toContextAction), | ||
| }; | ||
| } | ||
|
|
||
| function toContextMeta({ schemaRevision, environmentId }: ContextMeta): ContextMeta { | ||
| const meta: ContextMeta = { schemaRevision }; | ||
|
|
||
| if (environmentId !== undefined) meta.environmentId = environmentId; | ||
|
|
||
| return meta; | ||
| } | ||
|
|
||
| export default function buildContext( | ||
| collections: ForestSchemaCollection[], | ||
| readModel: ReadModel, | ||
| meta: ContextMeta, | ||
| ): AgentContext { | ||
| return { | ||
| collections: collections.map(collection => toContextCollection(collection, readModel)), | ||
|
Tonours marked this conversation as resolved.
|
||
| meta: toContextMeta(meta), | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import type ReadModelStore from '../read-model/read-model-store'; | ||
| import type { Middleware } from 'koa'; | ||
|
|
||
| import buildContext from './build-context'; | ||
| import { resolveSchemaSnapshot } from '../http/agent-route-helpers'; | ||
|
|
||
| const CONTEXT_ROUTE = '/agent/v1/context'; | ||
|
|
||
| export interface ContextRoutesMiddlewareOptions { | ||
| store: ReadModelStore; | ||
| environmentId?: number; | ||
| } | ||
|
|
||
| export default function createContextRoutesMiddleware({ | ||
| store, | ||
| environmentId, | ||
| }: ContextRoutesMiddlewareOptions): Middleware { | ||
| return async function contextRoutesMiddleware(ctx, next) { | ||
| if (ctx.path !== CONTEXT_ROUTE || ctx.method !== 'GET') { | ||
| await next(); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| const { collections, readModel, revision } = await resolveSchemaSnapshot(store); | ||
|
|
||
| ctx.status = 200; | ||
| ctx.body = buildContext(collections, readModel, { schemaRevision: revision, environmentId }); | ||
| }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ForestSchemaFielddeclaresenum, the agent emitsenums— fixing the type upstream inforestadmin-clientwould drop this intersection and theas unknown ascast in the fixture.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed on the diagnosis:
ForestSchemaFielddeclaresenum, the agent emitsenums(generator-fields.ts:71, generator-actions.ts:45), andunfolding.ts:80already works around the same gap.Not doing it in this PR though — fixing the declared type in
forestadmin-clientchanges a published type for every consumer of that package, which is a wider blast radius than a BFF route deserves to carry. Worth its own ticket; I'll open one. Until then the intersection type is namedFieldWithWireEnumsso the reason is visible at the use site rather than hidden in a cast.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Follow-up on the ticket I promised: PRD-993 — "forestadmin-client: ForestSchemaField declares
enumbut the wire sendsenums", currently In Review. Once that lands,FieldWithWireEnumshere and theas unknown ascasts in the fixture both go away.Leaving this PR as is unless you want the upstream fix folded in — say so and I will.