@autobe/agent
Version:
AI backend server code generator
739 lines (686 loc) • 26.5 kB
text/typescript
import {
AutoBeDatabase,
AutoBeInterfaceSchemaDesign,
AutoBeOpenApi,
} from "@autobe/interface";
import { AutoBeOpenApiTypeChecker, StringUtil } from "@autobe/utils";
import { OpenApiConverter, OpenApiTypeChecker } from "@typia/utils";
import typia, { OpenApi, tags } from "typia";
import { v7 } from "uuid";
import { AutoBeInterfaceSchemaProgrammer } from "../programmers/AutoBeInterfaceSchemaProgrammer";
import { AutoBeJsonSchemaCollection } from "./AutoBeJsonSchemaCollection";
import { AutoBeJsonSchemaValidator } from "./AutoBeJsonSchemaValidator";
export namespace AutoBeJsonSchemaFactory {
/* -----------------------------------------------------------
ASSIGNMENTS
----------------------------------------------------------- */
export const presets = (
typeNames: Set<string>,
): Record<string, AutoBeOpenApi.IJsonSchemaDescriptive> => {
const schemas: Record<string, AutoBeOpenApi.IJsonSchemaDescriptive> = {};
for (const [key, value] of Object.entries(DEFAULT_SCHEMAS)) {
schemas[key] = value;
typeNames.delete(key);
}
for (const key of typeNames)
if (AutoBeJsonSchemaValidator.isPage(key)) {
const data: string = getPageName(key);
schemas[key] = writePageSchema(data);
typeNames.delete(key);
typeNames.add(data);
}
return schemas;
};
export const fixPaginationSchemas = (
schemas: Record<string, AutoBeOpenApi.IJsonSchemaDescriptive>,
): void => {
const pageRequest: AutoBeOpenApi.IJsonSchemaDescriptive.IObject =
DEFAULT_SCHEMAS[
"IPage.IRequest"
] as AutoBeOpenApi.IJsonSchemaDescriptive.IObject;
for (const [key, value] of Object.entries(schemas)) {
if (key.endsWith(".IRequest") === false) continue;
else if (AutoBeOpenApiTypeChecker.isObject(value) === false) continue;
if (value.properties.page === undefined)
value.properties.page = pageRequest.properties.page;
if (value.properties.limit === undefined)
value.properties.limit = pageRequest.properties.limit;
}
// Rewrite every $ref pointing to a bogus .IPagination variant
// (e.g. IEcommerceMall.IPagination) → IPage.IPagination.
for (const value of Object.values(schemas))
AutoBeOpenApiTypeChecker.skim({
schema: value,
accessor: "",
closure: (next) => {
if (
AutoBeOpenApiTypeChecker.isReference(next) &&
next.$ref.endsWith(".IPagination") &&
next.$ref !== "#/components/schemas/IPage.IPagination"
)
next.$ref = "#/components/schemas/IPage.IPagination";
},
});
// Delete the bogus schemas themselves so the LLM never sees them
// in subsequent iterations. Covers both entity variants
// (IEcommerceMall.IPagination) and their page wrappers
// (IPageIEcommerceMall.IPagination).
for (const key of Object.keys(schemas))
if (key.endsWith(".IPagination") && key !== "IPage.IPagination")
delete schemas[key];
};
export const fixAuthorizationSchemas = (
schemas: Record<string, AutoBeOpenApi.IJsonSchemaDescriptive>,
): void => {
for (const [key, value] of Object.entries(schemas)) {
if (key.endsWith(".IAuthorized") === false) continue;
else if (AutoBeOpenApiTypeChecker.isObject(value) === false) continue;
const parent: AutoBeOpenApi.IJsonSchemaDescriptive | undefined =
schemas[key.replace(".IAuthorized", "")];
if (
parent === undefined ||
AutoBeOpenApiTypeChecker.isObject(parent) === false
) {
value.properties.token = {
"x-autobe-specification":
"Authorization token comes from the session table.",
description: "Authorization token.",
$ref: "#/components/schemas/IAuthorizationToken",
};
if (value.required.includes("token") === false)
value.required.push("token");
} else {
value.properties = {
...parent.properties,
...value.properties,
};
value.properties.token = {
"x-autobe-specification":
"Authorization token comes from the session table.",
description: "Authorization token.",
$ref: "#/components/schemas/IAuthorizationToken",
};
value.required = Array.from(
new Set([...parent.required, ...value.required]),
);
if (value.required.includes("id") === false) value.required.push("id");
if (value.required.includes("token") === false)
value.required.push("token");
}
}
};
export const finalize = (props: {
application: AutoBeDatabase.IApplication;
operations: AutoBeOpenApi.IOperation[];
collection: AutoBeJsonSchemaCollection;
}): void => {
removeDuplicated(props);
fixTimestamps({
application: props.application,
document: {
operations: props.operations,
components: {
schemas: props.collection.schemas,
authorizations: [],
},
},
});
linkRelatedModels({
application: props.application,
document: {
operations: props.operations,
components: {
schemas: props.collection.schemas,
authorizations: [],
},
},
});
};
export const removeUnused = (props: {
operations: AutoBeOpenApi.IOperation[];
schemas: Record<string, AutoBeOpenApi.IJsonSchemaDescriptive>;
}): void => {
while (true) {
const used: Set<string> = new Set();
const visit = (schema: AutoBeOpenApi.IJsonSchema): void =>
OpenApiTypeChecker.visit({
components: { schemas: props.schemas },
schema,
closure: (next) => {
if (OpenApiTypeChecker.isReference(next)) {
const key: string = next.$ref.split("/").pop()!;
used.add(key);
}
},
});
for (const op of props.operations) {
if (op.requestBody !== null)
visit({
$ref: `#/components/schemas/${op.requestBody.typeName}`,
});
if (op.responseBody !== null)
visit({
$ref: `#/components/schemas/${op.responseBody.typeName}`,
});
}
const complete: boolean =
Object.keys(props.schemas).length === 0 ||
Object.keys(props.schemas).every((key) => used.has(key) === true);
if (complete === true) break;
for (const key of Object.keys(props.schemas))
if (used.has(key) === false) delete props.schemas[key];
}
};
const removeDuplicated = (props: {
operations: AutoBeOpenApi.IOperation[];
collection: AutoBeJsonSchemaCollection;
}): void => {
// gather duplicated schemas
const correct: Map<string, string> = new Map();
for (const key of Object.keys(props.collection.schemas)) {
if (key.includes(".") === false) continue;
const dotRemoved: string = key.replace(".", "");
if (props.collection.schemas[dotRemoved] === undefined) continue;
correct.set(dotRemoved, key);
}
// fix operations' references
for (const op of props.operations) {
if (op.requestBody && correct.has(op.requestBody.typeName))
op.requestBody.typeName = correct.get(op.requestBody.typeName)!;
if (op.responseBody && correct.has(op.responseBody.typeName))
op.responseBody.typeName = correct.get(op.responseBody.typeName)!;
}
// fix schemas' references
const $refChangers: Map<OpenApi.IJsonSchema, () => void> = new Map();
for (const value of Object.values(props.collection.schemas))
OpenApiTypeChecker.visit({
components: { schemas: props.collection.schemas },
schema: value,
closure: (next) => {
if (OpenApiTypeChecker.isReference(next) === false) return;
const x: string = next.$ref.split("/").pop()!;
const y: string | undefined = correct.get(x);
if (y === undefined) return;
$refChangers.set(
next,
() => (next.$ref = `#/components/schemas/${y}`),
);
},
});
for (const fn of $refChangers.values()) fn();
// remove duplicated schemas
for (const key of correct.keys()) props.collection.delete(key);
};
const fixTimestamps = (props: {
document: AutoBeOpenApi.IDocument;
application: AutoBeDatabase.IApplication;
}): void => {
const entireModels: AutoBeDatabase.IModel[] = props.application.files
.map((f) => f.models)
.flat();
for (const value of Object.values(props.document.components.schemas)) {
if (AutoBeOpenApiTypeChecker.isObject(value) === false) continue;
const model: AutoBeDatabase.IModel | undefined = value[
"x-autobe-database-schema"
]
? entireModels.find((m) => m.name === value["x-autobe-database-schema"])
: undefined;
if (model === undefined) continue;
const properties: string[] = Object.keys(value.properties);
for (const key of properties) {
if (
key !== "created_at" &&
key !== "updated_at" &&
key !== "deleted_at"
)
continue;
const column: AutoBeDatabase.IPlainField | undefined =
model.plainFields.find((c) => c.name === key);
if (column === undefined) delete value.properties[key];
}
}
};
const linkRelatedModels = (props: {
document: AutoBeOpenApi.IDocument;
application: AutoBeDatabase.IApplication;
}): void => {
const modelDict: Set<string> = new Set(
props.application.files
.map((f) => f.models)
.flat()
.map((m) => m.name),
);
for (const [key, value] of Object.entries(
props.document.components.schemas,
)) {
if (
AutoBeOpenApiTypeChecker.isObject(value) === false ||
!!value["x-autobe-database-schema"]?.length
)
continue;
const typeName: string = key.split(".")[0]!.substring(1);
const modelName: string =
AutoBeInterfaceSchemaProgrammer.getDatabaseSchemaName(typeName);
if (modelDict.has(modelName) === true)
value["x-autobe-database-schema"] = modelName;
}
};
/* -----------------------------------------------------------
PAGINATION
----------------------------------------------------------- */
export const writePageSchema = (
key: string,
): AutoBeOpenApi.IJsonSchemaDescriptive.IObject => ({
type: "object",
properties: {
pagination: {
"x-autobe-specification": "Pagination information for the page.",
description: "Page information.",
$ref: "#/components/schemas/IPage.IPagination",
},
data: {
"x-autobe-specification": `List of records of type ${key}.`,
description: "List of records.",
type: "array",
items: {
$ref: `#/components/schemas/${key}`,
},
},
},
required: ["pagination", "data"],
description: StringUtil.trim`
A page.
Collection of records with pagination information.
`,
"x-autobe-specification": `A page containing records of type ${key}.`,
"x-autobe-database-schema": null, // filled by relation review agent
});
// export const fixPage = (path: string, input: unknown): void => {
// if (isRecord(input) === false || isRecord(input[path]) === false) return;
// if (input[path].description) delete input[path].description;
// if (input[path].required) delete input[path].required;
// for (const key of Object.keys(input[path]))
// if (DEFAULT_SCHEMAS[key] !== undefined)
// input[path][key] = DEFAULT_SCHEMAS[key];
// else if (AutoBeJsonSchemaValidator.isPage(key) === true) {
// const data: string = key.substring("IPage".length);
// input[path][key] = writePageSchema(data);
// }
// };
export const getPageName = (key: string): string =>
key.substring("IPage".length);
// const isRecord = (input: unknown): input is Record<string, unknown> =>
// typeof input === "object" && input !== null;
export const DEFAULT_SCHEMAS = (() => {
const init: Record<string, AutoBeOpenApi.IJsonSchemaDescriptive> =
(typia.json.schemas<
[IPage.IPagination, IPage.IRequest, IAuthorizationToken, IEntity]
>().components?.schemas ?? {}) as Record<
string,
AutoBeOpenApi.IJsonSchemaDescriptive
>;
for (const value of Object.values(init))
AutoBeOpenApiTypeChecker.visit({
components: {
schemas: init,
authorizations: [],
},
schema: value,
closure: (next) => {
if (AutoBeOpenApiTypeChecker.isObject(next)) {
next["x-autobe-database-schema"] = null;
}
},
});
return init;
})();
/* -----------------------------------------------------------
PLUGIN
----------------------------------------------------------- */
export const fixDesign = (
design: AutoBeInterfaceSchemaDesign,
): AutoBeOpenApi.IJsonSchema => {
const emended: AutoBeOpenApi.IJsonSchema = fixSchema(design.schema);
const final: AutoBeOpenApi.IJsonSchema = {
...emended,
...({
description: design.description,
"x-autobe-specification": design.specification,
} satisfies Pick<
AutoBeOpenApi.IJsonSchemaDescriptive,
"description" | "x-autobe-specification"
>),
};
if (AutoBeOpenApiTypeChecker.isObject(final))
final["x-autobe-database-schema"] = design.databaseSchema;
return final;
};
export const fixSchema = <Schema extends AutoBeOpenApi.IJsonSchema>(
schema: Schema,
): Schema => {
const id: string = v7();
const emended: AutoBeOpenApi.IJsonSchema = (
(OpenApiConverter.upgradeComponents({
schemas: {
[id]: schema,
},
}).schemas ?? {}) as Record<string, AutoBeOpenApi.IJsonSchema>
)[id];
const visited: WeakSet<object> = new WeakSet();
if (AutoBeOpenApiTypeChecker.isObject(emended)) {
visited.add(emended);
for (const v of Object.values(emended.properties)) visited.add(v);
}
AutoBeOpenApiTypeChecker.visit({
components: {
authorizations: [],
schemas: {},
},
schema: emended,
closure(next) {
if (visited.has(next) === false)
for (const k of Object.keys(next))
if (k.startsWith("x-")) {
// biome-ignore lint: intended
delete (next as any)[k];
}
if (AutoBeOpenApiTypeChecker.isString(next)) fixStringSchema(next);
else if (AutoBeOpenApiTypeChecker.isArray(next)) fixArraySchema(next);
else if (AutoBeOpenApiTypeChecker.isInteger(next))
fixIntegerSchema(next);
else if (AutoBeOpenApiTypeChecker.isNumber(next)) fixNumberSchema(next);
},
});
const result: Schema = emended as Schema;
if (AutoBeOpenApiTypeChecker.isObject(result))
for (const [key, value] of Object.entries(result.properties)) {
if (key !== "id" && key.endsWith("_id") === false) continue;
else if (AutoBeOpenApiTypeChecker.isString(value))
fixReferenceIdSchema(value);
else if (AutoBeOpenApiTypeChecker.isOneOf(value)) {
const str: AutoBeOpenApi.IJsonSchema.IString | undefined =
value.oneOf.find((v) => AutoBeOpenApiTypeChecker.isString(v));
if (str !== undefined) fixReferenceIdSchema(str);
}
}
return result;
};
const convertConst = (
schema:
| AutoBeOpenApi.IJsonSchema.INumber
| AutoBeOpenApi.IJsonSchema.IInteger,
value: number,
): void => {
// biome-ignore lint: @todo
const description: string | undefined = (schema as any).description;
for (const key of Object.keys(schema)) {
// biome-ignore lint: @todo
delete (schema as any)[key];
}
// biome-ignore lint: @todo
(schema as any).const = value;
if (description !== undefined) {
// biome-ignore lint: @todo
(schema as any).description = description;
}
};
const fixStringSchema = (schema: AutoBeOpenApi.IJsonSchema.IString): void => {
if (schema.format !== undefined) {
delete schema.pattern;
if (
schema.format === "uuid" ||
schema.format === "ipv4" ||
schema.format === "ipv6" ||
schema.format === "date" ||
schema.format === "date-time" ||
schema.format === "time"
) {
delete schema.minLength;
delete schema.maxLength;
delete schema.contentMediaType;
}
}
if (schema.contentMediaType === "") delete schema.contentMediaType;
if (schema.minLength === 0) delete schema.minLength;
};
const fixArraySchema = (schema: AutoBeOpenApi.IJsonSchema.IArray): void => {
if (schema.minItems === 0) delete schema.minItems;
};
/**
* Fix integer schema by converting single valid value ranges to const.
*
* Handles:
*
* - Minimum === maximum → const
* - Minimum: N, exclusiveMaximum: N+1 → const N
* - ExclusiveMinimum: N-1, maximum: N → const N
* - ExclusiveMinimum: N-1, exclusiveMaximum: N+1 → const N
*/
const fixIntegerSchema = (
schema: AutoBeOpenApi.IJsonSchema.IInteger,
): void => {
const value: number | undefined = (() => {
if (schema.minimum !== undefined && schema.maximum === schema.minimum)
return schema.minimum;
if (
schema.minimum !== undefined &&
schema.exclusiveMaximum === schema.minimum + 1
)
return schema.minimum;
if (
schema.maximum !== undefined &&
schema.exclusiveMinimum === schema.maximum - 1
)
return schema.maximum;
if (
schema.exclusiveMinimum !== undefined &&
schema.exclusiveMaximum === schema.exclusiveMinimum + 2
)
return schema.exclusiveMinimum + 1;
return undefined;
})();
if (value !== undefined) convertConst(schema, value);
};
/**
* Fix number schema by converting single valid value ranges to const.
*
* Handles:
*
* - Minimum === maximum → const
*/
const fixNumberSchema = (schema: AutoBeOpenApi.IJsonSchema.INumber): void => {
// minimum === maximum → const
if (
schema.minimum !== undefined &&
schema.maximum !== undefined &&
schema.minimum === schema.maximum
)
return convertConst(schema, schema.minimum);
};
const fixReferenceIdSchema = (
schema: AutoBeOpenApi.IJsonSchema.IString,
): void => {
schema.format = "uuid";
fixStringSchema(schema);
};
}
namespace IPage {
/**
* Pagination metadata containing current page position and total data
* statistics.
*
* This interface provides comprehensive pagination information returned
* alongside paginated list data. It enables clients to implement navigation
* controls, display progress indicators, and determine data boundaries for UI
* rendering.
*
* @x-autobe-specification Pagination metadata for paginated list responses. Included in all list endpoint responses.
*/
export interface IPagination {
/**
* Current page number being viewed (1-indexed).
*
* Indicates which page of results is currently being returned. Page
* numbering starts from 1, so the first page is page 1 (not 0). This value
* reflects the page parameter from the request after validation and bounds
* checking.
*
* @x-autobe-specification 1-indexed current page number. Defaults to 1.
*/
current: number & tags.Type<"uint32">;
/**
* Maximum number of records per page.
*
* Defines the upper bound on how many records can be returned in a single
* page. This corresponds to the limit parameter from the request. The
* actual number of records in the data array may be less than this value on
* the final page or when total records are fewer than the limit.
*
* @x-autobe-specification Maximum records per page. Actual count may be less on last page.
*/
limit: number & tags.Type<"uint32">;
/**
* Total count of all records matching the query criteria.
*
* Represents the complete number of records available across all pages, not
* just the current page. This value is computed via a COUNT query and is
* essential for calculating total pages and displaying pagination UI
* elements like "Showing 1-10 of 150 results".
*
* @x-autobe-specification Total record count across all pages.
*/
records: number & tags.Type<"uint32">;
/**
* Total number of pages available.
*
* Calculated as ceiling of {@link records} divided by {@link limit}. When
* records is 0, pages will also be 0. This value enables clients to render
* page navigation controls and validate page bounds.
*
* @x-autobe-specification Total pages. Calculated as Math.ceil(records / limit).
*/
pages: number & tags.Type<"uint32">;
}
/**
* Pagination request parameters for list endpoints.
*
* Defines the query parameters used to control pagination when requesting
* list data. Both parameters are optional with sensible defaults, allowing
* clients to fetch data without specifying pagination if default behavior is
* acceptable.
*
* @x-autobe-specification Pagination query parameters for list endpoints. All fields optional.
*/
export interface IRequest {
/**
* Target page number to retrieve (1-indexed).
*
* Specifies which page of results to return. Page numbering starts from 1.
* If omitted, null, or undefined, defaults to page 1 (first page).
* Requesting a page beyond the available range returns an empty data array
* with valid pagination metadata reflecting the actual totals.
*
* @x-autobe-specification 1-indexed page number. Defaults to 1 if not provided.
*/
page?: null | (number & tags.Type<"uint32">);
/**
* Maximum number of records to return per page.
*
* Controls how many records are included in each page response. If omitted,
* null, or undefined, defaults to 100 records per page. The server may
* enforce upper bounds to prevent excessive resource consumption on large
* requests.
*
* @default 100
*
* @x-autobe-specification Maximum records per page. Defaults to 100 if not provided.
*/
limit?: null | (number & tags.Type<"uint32">);
}
}
/**
* JWT-based authorization token pair with expiration metadata.
*
* Provides a complete authentication token structure containing both access and
* refresh tokens along with their respective expiration timestamps. This
* dual-token pattern enables secure, stateless authentication with automatic
* session renewal capabilities.
*
* The access token is short-lived for security, while the refresh token allows
* obtaining new access tokens without requiring the user to re-enter
* credentials. This structure is automatically included in authentication
* responses across all generated backend applications.
*
* @x-autobe-specification Dual-token authentication structure with access/refresh tokens and expiration info.
*/
interface IAuthorizationToken {
/**
* Short-lived JWT access token for authenticating API requests.
*
* This token must be included in the Authorization header using the Bearer
* scheme (e.g., `Authorization: Bearer {access}`) for all endpoints requiring
* authentication. The token contains encoded claims including user identity,
* roles, and permissions. Typically expires within 15-60 minutes for
* security; use the refresh token to obtain a new access token when expired.
*
* @x-autobe-specification JWT access token. Use in Authorization header as "Bearer {access}".
*/
access: string;
/**
* Long-lived refresh token for obtaining new access tokens.
*
* Used to request new access tokens when the current access token expires,
* allowing session continuation without re-authentication. Should be stored
* securely and transmitted only to the token refresh endpoint. Typical
* lifetime ranges from 7 to 30 days depending on security requirements.
*
* @x-autobe-specification Refresh token for obtaining new access tokens without re-authentication.
*/
refresh: string;
/**
* ISO 8601 timestamp when the access token expires.
*
* After this timestamp, the access token will be rejected by authenticated
* endpoints. Clients should proactively refresh before expiration to maintain
* seamless user experience. A common strategy is to refresh when remaining
* time falls below 5 minutes. This timestamp is also embedded within the JWT
* itself as the "exp" claim.
*
* @x-autobe-specification Access token expiration timestamp in ISO 8601 format.
*/
expired_at: string & tags.Format<"date-time">;
/**
* ISO 8601 timestamp indicating the absolute session expiration deadline.
*
* Represents the latest possible time the refresh token can be used. Once
* this timestamp is reached, the user must fully re-authenticate with
* credentials. This defines the maximum session duration regardless of
* activity. If refresh token rotation is enabled, this deadline may extend
* with each successful refresh.
*
* @x-autobe-specification Refresh token expiration timestamp. Re-authentication required after this time.
*/
refreshable_until: string & tags.Format<"date-time">;
}
/**
* Base entity interface providing standard primary key identification.
*
* Serves as the foundational interface for all database entities in the
* generated application. Every model and record type extends this interface,
* ensuring consistent identification semantics across all database tables and
* API responses.
*
* @x-autobe-specification Base interface for all database entities. Contains the primary key.
*/
interface IEntity {
/**
* Unique identifier for this entity (UUID format).
*
* Auto-generated primary key using UUID format. This value is assigned by the
* system upon record creation and cannot be modified afterward. All foreign
* key relationships in the database reference this field.
*
* @x-autobe-specification Primary key in UUID format. Auto-generated, read-only.
*/
id: string & tags.Format<"uuid">;
}