From 5791b4d933028bd8f81e6787a5f9a040c7f935a2 Mon Sep 17 00:00:00 2001 From: ACQ Build Date: Sun, 14 Jun 2026 20:53:12 -0500 Subject: [PATCH 1/3] feat: per-entity http flag to suppress the generated controller (#93) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-entity `http` flag (with optional top-level default; effective value `entity.http ?? ?? true`). When false, the generator still emits the entity, service, DTOs, and module (TypeOrmModule.forFeature + service) but omits the controller — neither generating the controller file nor wiring it into `controllers: []` — so a hand-written controller can own the route without a collision. REST only (GraphQL uses resolvers). Docs in the MCP schema reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/generate.ts | 42 ++++++++++++++------ src/commands/mcp/serve.ts | 9 +++++ src/lib/apsorc-parser.ts | 11 ++++++ src/lib/generators/typescript.ts | 3 +- src/lib/templates/rest/module-rest.eta | 9 ++--- src/lib/types/entity.ts | 12 ++++++ src/lib/types/generator.ts | 12 ++++++ test/lib/generators/typescript.test.ts | 53 ++++++++++++++++++++++++++ 8 files changed, 134 insertions(+), 17 deletions(-) diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 4c61bad..c4c57cf 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,19 @@ 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 (REST only): 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. + const includeController = + 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 +172,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..8b45f51 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,14 @@ 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 extension controller owns the route +for that entity (e.g. a custom/Stripe-shaped surface), avoiding a route collision +with the generated CRUD. Effective value is \`entity.http ?? ?? true\`. + ## 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..0854c15 100644 --- a/src/lib/types/entity.ts +++ b/src/lib/types/entity.ts @@ -66,6 +66,18 @@ 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 extension controller (no route + * collision with the generated CRUD). + * + * 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"); + }); +}); From d372fd4aeec0cf52abc6be1fcdbbb698c1ee71ec Mon Sep 17 00:00:00 2001 From: ACQ Build Date: Sun, 14 Jun 2026 21:00:10 -0500 Subject: [PATCH 2/3] fix: scope http controller flag to TypeScript (no-op for python/go) The Python (FastAPI) and Go (Gin) generators wire routers differently and don't honor includeController yet; gating the controller skip to typescript keeps the flag a safe no-op for them (controllers still generated) instead of emitting broken wiring. Tracked separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/generate.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/commands/generate.ts b/src/commands/generate.ts index c4c57cf..bf297be 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -140,12 +140,18 @@ export default class Generate extends BaseCommand { const entityBuildStart = performance.now(); const entityRelationships = relationshipMap[entity.name] || []; - // Effective HTTP-controller flag (REST only): 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. + // 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 = - lowerCaseApiType === "rest" ? (entity.http ?? http ?? true) : true; + language === "typescript" && lowerCaseApiType === "rest" + ? (entity.http ?? http ?? true) + : true; if (!includeController) { console.log( `[apso] ${entity.name}: http=false — skipping generated controller` From e5aaede4588080a08feb20e91b4df2529b28b7e1 Mon Sep 17 00:00:00 2001 From: ACQ Build Date: Sun, 14 Jun 2026 21:15:04 -0500 Subject: [PATCH 3/3] docs: clarify http flag is cross-language (NestJS+Gin suppress; FastAPI no-op) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/mcp/serve.ts | 12 +++++++++--- src/lib/types/entity.ts | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/commands/mcp/serve.ts b/src/commands/mcp/serve.ts index 8b45f51..e912696 100644 --- a/src/commands/mcp/serve.ts +++ b/src/commands/mcp/serve.ts @@ -1120,9 +1120,15 @@ CLI no longer generates the engine code. 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 extension controller owns the route -for that entity (e.g. a custom/Stripe-shaped surface), avoiding a route collision -with the generated CRUD. Effective value is \`entity.http ?? ?? true\`. ++ 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 diff --git a/src/lib/types/entity.ts b/src/lib/types/entity.ts index 0854c15..9e5b1df 100644 --- a/src/lib/types/entity.ts +++ b/src/lib/types/entity.ts @@ -70,8 +70,18 @@ export interface Entity { * 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 extension controller (no route - * collision with the generated CRUD). + * 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).