Skip to content

Commit 48815e1

Browse files
committed
feat: enhance OpenAPI documentation generation with new schema handling and descriptions
1 parent 067cec6 commit 48815e1

5 files changed

Lines changed: 576 additions & 94 deletions

File tree

adminforth/documentation/docs/tutorial/03-Customization/06-customPages.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,39 @@ Now we have to define this endpoint in the backend to make our page work:
303303
304304
## Defining custom API for own page and components
305305
306+
> ☝️ Using `admin.express.withSchema(...)` is the recommended approach because it adds your route to `/api/v1/openapi.json` and `/api-docs` (Solar), performs early runtime validation for API calls, and gives agent plugins a machine-readable API contract they can use in skills. It is still optional though, and you can register plain Express routes without `withSchema(...)` if you prefer.
307+
308+
> ☝️ If you do not want to use Zod, you can pass a plain JSON Schema object instead of a Zod schema. For example, this Zod response schema:
309+
>
310+
> ```ts
311+
> response: z.object({
312+
> apartsByDays: z.array(z.record(z.string(), z.unknown())),
313+
> totalAparts: z.number(),
314+
> }).catchall(z.unknown()),
315+
> ```
316+
>
317+
> can be written as pure JSON Schema:
318+
>
319+
> ```ts
320+
> response: {
321+
> type: 'object',
322+
> properties: {
323+
> apartsByDays: {
324+
> type: 'array',
325+
> items: {
326+
> type: 'object',
327+
> additionalProperties: true,
328+
> },
329+
> },
330+
> totalAparts: {
331+
> type: 'number',
332+
> },
333+
> },
334+
> required: ['apartsByDays', 'totalAparts'],
335+
> additionalProperties: true,
336+
> },
337+
> ```
338+
306339
307340
Open `index.ts` file and add the following code *BEFORE* `admin.express.serve(` !
308341

adminforth/modules/restApi.ts

Lines changed: 105 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ const genericObjectSchema: AnySchemaObject = {
8888
};
8989

9090
const errorResponseSchema: AnySchemaObject = {
91+
title: 'AdminForthErrorResponse',
92+
description: 'Standard error response returned by AdminForth endpoints.',
9193
type: 'object',
9294
required: ['error'],
9395
properties: {
@@ -97,20 +99,25 @@ const errorResponseSchema: AnySchemaObject = {
9799
};
98100

99101
const recordIdentifierSchema: AnySchemaObject = {
102+
title: 'AdminForthRecordIdentifier',
103+
description: 'Record identifier accepted by AdminForth. Depending on the resource it can be a string or a number.',
100104
anyOf: [
101105
{ type: 'string' },
102106
{ type: 'number' },
103107
],
104108
};
105109

106110
const actionIdentifierSchema: AnySchemaObject = {
111+
title: 'AdminForthActionIdentifier',
112+
description: 'Action identifier accepted by AdminForth. Depending on configuration it can be a string or a number.',
107113
anyOf: [
108114
{ type: 'string' },
109115
{ type: 'number' },
110116
],
111117
};
112118

113119
const namedColumnSchema: AnySchemaObject = {
120+
title: 'AdminForthNamedColumn',
114121
type: 'object',
115122
required: ['name'],
116123
properties: {
@@ -120,6 +127,7 @@ const namedColumnSchema: AnySchemaObject = {
120127
};
121128

122129
const validationResultSchema: AnySchemaObject = {
130+
title: 'AdminForthValidationResult',
123131
type: 'object',
124132
required: ['isValid'],
125133
properties: {
@@ -129,56 +137,97 @@ const validationResultSchema: AnySchemaObject = {
129137
additionalProperties: true,
130138
};
131139

132-
const commonFilterSchemaDefs: Record<string, AnySchemaObject> = {
133-
singleFilter: {
134-
type: 'object',
135-
properties: {
136-
field: { type: 'string' },
137-
operator: { type: 'string', enum: SIMPLE_FILTER_OPERATORS },
138-
value: {},
139-
rightField: { type: 'string' },
140-
insecureRawSQL: { type: 'string' },
141-
insecureRawNoSQL: {},
140+
const filterConditionExample = {
141+
field: 'status',
142+
operator: AdminForthFilterOperators.EQ,
143+
value: 'active',
144+
};
145+
146+
const filterGroupExample = {
147+
operator: AdminForthFilterOperators.AND,
148+
subFilters: [filterConditionExample],
149+
};
150+
151+
const sortItemExample = {
152+
field: 'createdAt',
153+
direction: AdminForthSortDirections.desc,
154+
};
155+
156+
const filterConditionSchema: AnySchemaObject = {
157+
title: 'AdminForthFilterCondition',
158+
description: 'Single field comparison used in AdminForth filtering.',
159+
type: 'object',
160+
properties: {
161+
field: { type: 'string' },
162+
operator: { type: 'string', enum: SIMPLE_FILTER_OPERATORS },
163+
value: {},
164+
rightField: { type: 'string' },
165+
insecureRawSQL: { type: 'string' },
166+
insecureRawNoSQL: {},
167+
},
168+
additionalProperties: true,
169+
examples: [filterConditionExample],
170+
};
171+
172+
const filterGroupSchema: AnySchemaObject = {
173+
title: 'AdminForthFilterGroup',
174+
description: 'Nested boolean filter group. Use this for AND or OR combinations of filter nodes.',
175+
type: 'object',
176+
required: ['operator', 'subFilters'],
177+
properties: {
178+
operator: {
179+
type: 'string',
180+
enum: [AdminForthFilterOperators.AND, AdminForthFilterOperators.OR],
181+
},
182+
subFilters: {
183+
type: 'array',
184+
items: { $ref: '#/$defs/filterNode' },
185+
description: 'Nested filters evaluated with the selected operator.',
142186
},
143-
additionalProperties: true,
144187
},
188+
additionalProperties: true,
189+
examples: [filterGroupExample],
190+
};
191+
192+
const sortItemSchema: AnySchemaObject = {
193+
title: 'AdminForthSortItem',
194+
description: 'Single sort instruction applied in order with the rest of the list.',
195+
type: 'object',
196+
required: ['field', 'direction'],
197+
properties: {
198+
field: { type: 'string' },
199+
direction: { type: 'string', enum: Object.values(AdminForthSortDirections) },
200+
},
201+
additionalProperties: true,
202+
examples: [sortItemExample],
203+
};
204+
205+
const commonFilterSchemaDefs: Record<string, AnySchemaObject> = {
206+
singleFilter: filterConditionSchema,
207+
filterGroup: filterGroupSchema,
145208
filterNode: {
209+
title: 'AdminForthFilterNode',
210+
description: 'Either a single filter condition or a nested filter group.',
146211
anyOf: [
147212
{ $ref: '#/$defs/singleFilter' },
148-
{
149-
type: 'object',
150-
required: ['operator', 'subFilters'],
151-
properties: {
152-
operator: {
153-
type: 'string',
154-
enum: [AdminForthFilterOperators.AND, AdminForthFilterOperators.OR],
155-
},
156-
subFilters: {
157-
type: 'array',
158-
items: { $ref: '#/$defs/filterNode' },
159-
},
160-
},
161-
additionalProperties: true,
162-
},
213+
{ $ref: '#/$defs/filterGroup' },
163214
],
215+
examples: [filterConditionExample, filterGroupExample],
164216
},
165-
sortItem: {
166-
type: 'object',
167-
required: ['field', 'direction'],
168-
properties: {
169-
field: { type: 'string' },
170-
direction: { type: 'string', enum: Object.values(AdminForthSortDirections) },
171-
},
172-
additionalProperties: true,
173-
},
217+
sortItem: sortItemSchema,
174218
};
175219

176220
const commonSortSchema: AnySchemaObject = {
221+
title: 'AdminForthSortList',
222+
description: 'Ordered list of sort instructions.',
177223
type: 'array',
178224
items: { $ref: '#/$defs/sortItem' },
225+
examples: [[sortItemExample]],
179226
};
180227

181228
const commonFiltersSchema: AnySchemaObject = {
229+
title: 'AdminForthFilterInput',
230+
description: 'Runtime accepts either a single filter node or an array of filter nodes. The OpenAPI document normalizes this to the array form for readability.',
182231
oneOf: [
183232
{
184233
type: 'array',
@@ -203,9 +252,19 @@ const getResourceDataRequestSchema: AnySchemaObject = {
203252
required: ['resourceId', 'source', 'limit', 'offset', 'filters', 'sort'],
204253
properties: {
205254
resourceId: { type: 'string' },
206-
source: { type: 'string', enum: ['show', 'list', 'edit'] },
207-
limit: { type: 'integer' },
208-
offset: { type: 'integer' },
255+
source: {
256+
type: 'string',
257+
enum: ['show', 'list', 'edit'],
258+
description: 'Target UI context. Show and edit requests should use direct field filters that identify a single record.',
259+
},
260+
limit: {
261+
type: 'integer',
262+
description: 'Maximum number of rows to return for the current page.',
263+
},
264+
offset: {
265+
type: 'integer',
266+
description: 'Zero-based row offset used for pagination.',
267+
},
209268
sort: commonSortSchema,
210269
filters: commonFiltersSchema,
211270
},
@@ -287,8 +346,14 @@ const getResourceForeignDataRequestSchema: AnySchemaObject = {
287346
properties: {
288347
resourceId: { type: 'string' },
289348
column: { type: 'string' },
290-
limit: { type: 'integer' },
291-
offset: { type: 'integer' },
349+
limit: {
350+
type: 'integer',
351+
description: 'Maximum number of dropdown options to return.',
352+
},
353+
offset: {
354+
type: 'integer',
355+
description: 'Zero-based offset used to fetch the next option page.',
356+
},
292357
search: { type: 'string' },
293358
filters: commonFiltersSchema,
294359
sort: commonSortSchema,

adminforth/servers/express.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,13 @@ async function parseExpressCookie(req): Promise<
5656

5757
const EXPRESS_ROUTE_SCHEMA = Symbol('adminforth.express.withSchema');
5858

59+
type RegisteredExpressRouteSchema = IAdminForthExpressRouteSchema & {
60+
request?: AnySchemaObject;
61+
response?: AnySchemaObject;
62+
};
63+
5964
type SchemaAnnotatedHandler = ((...args: any[]) => any) & {
60-
[EXPRESS_ROUTE_SCHEMA]?: IAdminForthExpressRouteSchema;
65+
[EXPRESS_ROUTE_SCHEMA]?: RegisteredExpressRouteSchema;
6166
};
6267

6368
type ZodSchemaLike = {
@@ -74,7 +79,7 @@ function isZodSchemaLike(schema: unknown): schema is ZodSchemaLike {
7479
&& ('_zod' in schema || '_def' in schema);
7580
}
7681

77-
function normalizeExpressSchema(schema: unknown): AnySchemaObject | undefined {
82+
function normalizeExpressRuntimeSchema(schema: unknown): AnySchemaObject | undefined {
7883
if (!schema) {
7984
return undefined;
8085
}
@@ -335,8 +340,8 @@ class ExpressServer implements IExpressHttpServer {
335340
const wrapped = ((...args: any[]) => handler(...args)) as SchemaAnnotatedHandler;
336341
wrapped[EXPRESS_ROUTE_SCHEMA] = {
337342
...schema,
338-
request: normalizeExpressSchema(schema.request),
339-
response: normalizeExpressSchema(schema.response),
343+
request: normalizeExpressRuntimeSchema(schema.request),
344+
response: normalizeExpressRuntimeSchema(schema.response),
340345
};
341346
return wrapped;
342347
}
@@ -444,7 +449,16 @@ class ExpressServer implements IExpressHttpServer {
444449
}
445450

446451
endpoint(options) {
447-
const { method='GET', path, handler, noAuth=false, description, request_schema, response_schema, responce_schema } = options;
452+
const {
453+
method='GET',
454+
path,
455+
handler,
456+
noAuth=false,
457+
description,
458+
request_schema,
459+
response_schema,
460+
responce_schema,
461+
} = options;
448462
if (!path.startsWith('/')) {
449463
throw new Error(`Path must start with /, got: ${path}`);
450464
}

adminforth/servers/openapi.ts

Lines changed: 7 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createRequire } from 'module';
22
import type { AnySchemaObject, ErrorObject, ValidateFunction } from 'ajv';
33
import { ADMINFORTH_VERSION } from '../modules/utils.js';
4+
import { buildOpenApiDocument } from './openapiDocument.js';
45
import {
56
type IAdminForth,
67
type IAdminForthApiValidationError,
@@ -113,55 +114,12 @@ class OpenApiRegistry implements IOpenApiRegistry {
113114
}
114115

115116
renderOpenApiDocument(): {[key: string]: any} {
116-
const paths = {} as Record<string, Record<string, unknown>>;
117-
118-
for (const route of this.registeredSchemas) {
119-
if (!route.request_schema && !route.response_schema) {
120-
continue;
121-
}
122-
123-
if (!paths[route.path]) {
124-
paths[route.path] = {};
125-
}
126-
127-
paths[route.path][route.method] = {
128-
...(route.description ? {
129-
description: route.description,
130-
} : {}),
131-
...(route.request_schema ? {
132-
requestBody: {
133-
required: true,
134-
content: {
135-
'application/json': {
136-
schema: route.request_schema,
137-
},
138-
},
139-
},
140-
} : {}),
141-
responses: {
142-
200: route.response_schema ? {
143-
description: 'Successful response',
144-
content: {
145-
'application/json': {
146-
schema: route.response_schema,
147-
},
148-
},
149-
} : {
150-
description: 'Successful response',
151-
},
152-
},
153-
};
154-
}
155-
156-
return {
157-
openapi: '3.0.3',
158-
info: {
159-
title: `${this.adminforth.config.customization.brandName || 'AdminForth'} API`,
160-
version: ADMINFORTH_VERSION,
161-
description: 'Generated from AdminForth endpoint schemas.',
162-
},
163-
paths,
164-
};
117+
return buildOpenApiDocument({
118+
title: `${this.adminforth.config.customization.brandName || 'AdminForth'} API`,
119+
version: ADMINFORTH_VERSION,
120+
description: 'Generated from AdminForth endpoint schemas.',
121+
routes: this.registeredSchemas,
122+
});
165123
}
166124

167125
private findCompiledRoute(route: IRegisteredApiSchema | null): CompiledApiSchema | null {

0 commit comments

Comments
 (0)