UNPKG

@autobe/agent

Version:

AI backend server code generator

739 lines (686 loc) 26.5 kB
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">; }