diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 4c61bad..bf297be 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -42,7 +42,7 @@ export default class Generate extends BaseCommand { const skipFormat = flags["skip-format"]; const totalBuildStart = performance.now(); - const { rootFolder, entities, relationshipMap, apiType, auth, emitEvents, language: configLanguage } = parseApsorc(); + const { rootFolder, entities, relationshipMap, apiType, auth, emitEvents, http, language: configLanguage } = parseApsorc(); // Resolve language: flag > .apsorc > prompt let language: TargetLanguage; @@ -103,6 +103,7 @@ export default class Generate extends BaseCommand { relationshipMap, auth, emitEvents, + http, }; const generator = createGenerator(generatorConfig); @@ -139,8 +140,25 @@ export default class Generate extends BaseCommand { const entityBuildStart = performance.now(); const entityRelationships = relationshipMap[entity.name] || []; - // eslint-disable-next-line no-await-in-loop - const allFiles = await Promise.all([ + // Effective HTTP-controller flag (TypeScript REST only for now): entity + // overrides the top-level default, which defaults to true. When false, the + // controller is neither generated nor wired into the module (a hand-written + // controller owns the route). GraphQL uses resolvers, so the flag doesn't + // apply there. Python (FastAPI) and Go (Gin) wire routers differently and + // don't yet honor this flag — keep it a no-op for them (controllers always + // generated) so we never emit broken wiring (a skipped router still + // referenced by the index module). Tracked separately for Python/Go. + const includeController = + language === "typescript" && lowerCaseApiType === "rest" + ? (entity.http ?? http ?? true) + : true; + if (!includeController) { + console.log( + `[apso] ${entity.name}: http=false — skipping generated controller` + ); + } + + const generationTasks = [ generator.generateEntity({ entity, relationships: entityRelationships, @@ -160,20 +178,28 @@ export default class Generate extends BaseCommand { apiType: lowerCaseApiType, relationshipMap, }), - generator.generateController({ - entity, - relationships: entityRelationships, - allEntities: entities, - apiType: lowerCaseApiType, - relationshipMap, - }), generator.generateModule({ entity, relationships: entityRelationships, allEntities: entities, apiType: lowerCaseApiType, + includeController, }), - ]); + ]; + if (includeController) { + generationTasks.push( + generator.generateController({ + entity, + relationships: entityRelationships, + allEntities: entities, + apiType: lowerCaseApiType, + relationshipMap, + }) + ); + } + + // eslint-disable-next-line no-await-in-loop + const allFiles = await Promise.all(generationTasks); // eslint-disable-next-line no-await-in-loop for (const files of allFiles) { diff --git a/src/commands/mcp/serve.ts b/src/commands/mcp/serve.ts index 1d7b174..e912696 100644 --- a/src/commands/mcp/serve.ts +++ b/src/commands/mcp/serve.ts @@ -1093,6 +1093,7 @@ const INLINE_SCHEMA_REFERENCE = `# Schema Quick Reference "primaryKeyType": "serial", "scopeBy": "organizationId", "emitEvents": true, + "http": false, "fields": [ { "name": "fieldName", "type": "text", "length": 100 } ], @@ -1115,6 +1116,20 @@ manifest at \`autogen/events/event-emitting.entities.ts\` exporting \`@apso/domain-events\` library and is wired by the \`domain-events\` skill; the CLI no longer generates the engine code. +## HTTP controllers (http) +Controllers are generated by default. Set \`http: false\` on an entity (or a +top-level \`http: false\` default) to NOT generate/mount its HTTP controller while +still generating the entity, service, DTOs, and module (\`TypeOrmModule.forFeature\` ++ the service). Use this when a hand-written controller owns the route for that +entity (e.g. a custom/Stripe-shaped surface). Effective value is +\`entity.http ?? ?? true\`. + +Per framework: NestJS and Gin (Go) actually suppress the controller — those +frameworks can't cleanly override a generated route. For FastAPI it's a no-op: +override by registering your router ahead of the generated \`autogen_router\` +(FastAPI matches in registration order). Currently implemented for NestJS; Go +is tracked separately. + ## Relationship Types - OneToMany: parent has many children - ManyToOne: child belongs to parent diff --git a/src/lib/apsorc-parser.ts b/src/lib/apsorc-parser.ts index 035caa0..970772e 100644 --- a/src/lib/apsorc-parser.ts +++ b/src/lib/apsorc-parser.ts @@ -30,6 +30,11 @@ export type ApsorcType = { * its own `emitEvents: false`. */ emitEvents?: boolean; + /** + * Top-level default for HTTP controller generation. When false, no entity + * gets a generated controller unless it opts back in with `http: true`. + */ + http?: boolean; }; type ParsedApsorcData = { @@ -45,6 +50,8 @@ type ParsedApsorc = { language?: TargetLanguage; /** Top-level default for the DomainEvent ("emitEvents") feature. */ emitEvents?: boolean; + /** Top-level default for HTTP controller generation. */ + http?: boolean; }; export const parseApsorcV1 = (apsorc: ApsorcType): ParsedApsorcData => { @@ -72,6 +79,7 @@ const parseRc = (): ApsorcType => { const auth = apsoConfig.auth as AuthConfig | undefined; const language = apsoConfig.language as TargetLanguage | undefined; const emitEvents = apsoConfig.emitEvents as boolean | undefined; + const http = apsoConfig.http as boolean | undefined; return { rootFolder, @@ -82,6 +90,7 @@ const parseRc = (): ApsorcType => { auth, language, emitEvents, + http, }; }; @@ -106,6 +115,7 @@ export const parseApsorc = (): ParsedApsorc => { auth: apsoConfig.auth, language: apsoConfig.language, emitEvents: apsoConfig.emitEvents, + http: apsoConfig.http, ...parseApsorcV1(apsoConfig), }; if (debug) { @@ -131,6 +141,7 @@ export const parseApsorc = (): ParsedApsorc => { auth: apsoConfig.auth, language: apsoConfig.language, emitEvents: apsoConfig.emitEvents, + http: apsoConfig.http, ...(() => { const relStart = performance.now(); const parsed = parseApsorcV2(apsoConfig); diff --git a/src/lib/generators/typescript.ts b/src/lib/generators/typescript.ts index b738013..952a3c5 100644 --- a/src/lib/generators/typescript.ts +++ b/src/lib/generators/typescript.ts @@ -362,7 +362,7 @@ export class TypeScriptGenerator extends BaseGenerator { async generateModule( options: EntityGenerationOptions ): Promise { - const { entity, apiType } = options; + const { entity, apiType, includeController = true } = options; const { name: entityName } = entity; const moduleName = `${entityName}Module`; @@ -376,6 +376,7 @@ export class TypeScriptGenerator extends BaseGenerator { ctrlName, resolverName, entityName, + includeController, }; const templatePath = diff --git a/src/lib/templates/rest/module-rest.eta b/src/lib/templates/rest/module-rest.eta index b06ce8b..8e59fea 100644 --- a/src/lib/templates/rest/module-rest.eta +++ b/src/lib/templates/rest/module-rest.eta @@ -4,13 +4,12 @@ import { Module } from "@nestjs/common"; import {<%= it.entityName %>} from './<%= it.entityName%>.entity' import { TypeOrmModule } from "@nestjs/typeorm"; import {<%= it.svcName %>} from "./<%= it.entityName %>.service"; -import {<%= it.ctrlName %>} from "./<%= it.entityName %>.controller"; - +<% if (it.includeController) { %>import {<%= it.ctrlName %>} from "./<%= it.entityName %>.controller"; +<% } %> @Module({ imports: [TypeOrmModule.forFeature([<%= it.entityName %>])], providers: [<%= it.svcName %>], exports: [<%= it.svcName %>], - controllers: [<%= it.ctrlName %>], -}) +<% if (it.includeController) { %> controllers: [<%= it.ctrlName %>], +<% } %>}) export class <%= it.moduleName %> {} - \ No newline at end of file diff --git a/src/lib/types/entity.ts b/src/lib/types/entity.ts index 722daa5..9e5b1df 100644 --- a/src/lib/types/entity.ts +++ b/src/lib/types/entity.ts @@ -66,6 +66,28 @@ export interface Entity { */ emitEvents?: boolean; + /** + * Whether to generate the HTTP controller for this entity. Defaults to true. + * When false, the generator still emits the entity, service, DTOs, and a module + * (with `TypeOrmModule.forFeature` + the service), but omits the controller — + * leaving the HTTP surface to a hand-written controller without a route + * collision with the generated CRUD. + * + * Per-framework behavior (the flag is generic, but only matters where a + * generated route can't be cleanly overridden): + * - **NestJS** and **Gin (Go)**: actually suppresses the controller/route — + * these frameworks can't override a generated route (Nest controllers aren't + * DI-swappable; Gin panics on duplicate paths). Implemented for NestJS; + * Go is tracked separately. + * - **FastAPI**: effectively a **no-op** — FastAPI matches routes in + * registration order, so an app overrides by registering its router ahead of + * the generated `autogen_router` (no suppression needed). + * + * Resolution: the effective value is `entity.http ?? ?? true`, so + * controllers are on by default and opted out per entity (or globally). + */ + http?: boolean; + // only used for v1 associations?: Association[]; } diff --git a/src/lib/types/generator.ts b/src/lib/types/generator.ts index f0baa15..299e7c9 100644 --- a/src/lib/types/generator.ts +++ b/src/lib/types/generator.ts @@ -134,6 +134,12 @@ export interface GeneratorConfig { */ emitEvents?: boolean; + /** + * Top-level default for HTTP controller generation. + * Effective per-entity value is `entity.http ?? http ?? true`. + */ + http?: boolean; + /** * Language-specific configuration options */ @@ -163,6 +169,12 @@ export interface EntityGenerationOptions { * API type being generated */ apiType: string; + + /** + * Whether the entity's HTTP controller is generated/wired. Defaults to true. + * When false, the module omits the controller (see Entity.http). + */ + includeController?: boolean; } /** diff --git a/test/lib/generators/typescript.test.ts b/test/lib/generators/typescript.test.ts index d4fb87f..773b9ed 100644 --- a/test/lib/generators/typescript.test.ts +++ b/test/lib/generators/typescript.test.ts @@ -396,3 +396,56 @@ describe("emitEvents / domain events manifest (issue #91)", () => { expect(indexContent).toContain("WidgetModule"); }); }); + +describe("http controller suppression (issue #93)", () => { + let generator: TypeScriptGenerator; + + beforeAll(() => { + generator = new TypeScriptGenerator(createConfig([])); + }); + + const entity: Entity = { + name: "Product", + primaryKeyType: "serial", + fields: [{ name: "name", type: "text", nullable: false }], + }; + + test("default (http on): module wires the controller", async () => { + const files = await generator.generateModule({ + entity, + relationships: [], + allEntities: [entity], + apiType: "rest", + }); + const moduleContent = findFileContent(files, "Product.module"); + expect(moduleContent).toBeDefined(); + expect(moduleContent).toContain( + 'import {ProductController} from "./Product.controller"' + ); + expect(moduleContent).toContain("controllers: [ProductController]"); + // data layer intact + expect(moduleContent).toContain("TypeOrmModule.forFeature([Product])"); + expect(moduleContent).toContain("providers: [ProductService]"); + }); + + test("http=false: module omits the controller but keeps entity/service wiring", async () => { + const files = await generator.generateModule({ + entity, + relationships: [], + allEntities: [entity], + apiType: "rest", + includeController: false, + }); + const moduleContent = findFileContent(files, "Product.module"); + expect(moduleContent).toBeDefined(); + // no controller import, no controllers array + expect(moduleContent).not.toContain("Product.controller"); + expect(moduleContent).not.toContain("controllers:"); + expect(moduleContent).not.toContain("ProductController"); + // service + repository wiring preserved + expect(moduleContent).toContain("TypeOrmModule.forFeature([Product])"); + expect(moduleContent).toContain("providers: [ProductService]"); + expect(moduleContent).toContain("exports: [ProductService]"); + expect(moduleContent).toContain("export class ProductModule"); + }); +});