Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 37 additions & 11 deletions src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -103,6 +103,7 @@ export default class Generate extends BaseCommand {
relationshipMap,
auth,
emitEvents,
http,
};

const generator = createGenerator(generatorConfig);
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions src/commands/mcp/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
],
Expand All @@ -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 ?? <top-level 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
Expand Down
11 changes: 11 additions & 0 deletions src/lib/apsorc-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 => {
Expand Down Expand Up @@ -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,
Expand All @@ -82,6 +90,7 @@ const parseRc = (): ApsorcType => {
auth,
language,
emitEvents,
http,
};
};

Expand All @@ -106,6 +115,7 @@ export const parseApsorc = (): ParsedApsorc => {
auth: apsoConfig.auth,
language: apsoConfig.language,
emitEvents: apsoConfig.emitEvents,
http: apsoConfig.http,
...parseApsorcV1(apsoConfig),
};
if (debug) {
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/generators/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ export class TypeScriptGenerator extends BaseGenerator {
async generateModule(
options: EntityGenerationOptions
): Promise<GeneratedFile[]> {
const { entity, apiType } = options;
const { entity, apiType, includeController = true } = options;
const { name: entityName } = entity;

const moduleName = `${entityName}Module`;
Expand All @@ -376,6 +376,7 @@ export class TypeScriptGenerator extends BaseGenerator {
ctrlName,
resolverName,
entityName,
includeController,
};

const templatePath =
Expand Down
9 changes: 4 additions & 5 deletions src/lib/templates/rest/module-rest.eta
Original file line number Diff line number Diff line change
Expand Up @@ -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 %> {}

22 changes: 22 additions & 0 deletions src/lib/types/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? <global http> ?? true`, so
* controllers are on by default and opted out per entity (or globally).
*/
http?: boolean;

// only used for v1
associations?: Association[];
}
12 changes: 12 additions & 0 deletions src/lib/types/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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;
}

/**
Expand Down
53 changes: 53 additions & 0 deletions test/lib/generators/typescript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Loading