7 Commits

Author SHA1 Message Date
77e3cbc0e9 Merge pull request 'chore: fix publish workflow' (#52) from fix/fix-publish into main
All checks were successful
Publish / publish (push) Successful in 2m23s
Reviewed-on: #52
2026-03-26 10:58:15 +00:00
16d4c8e0bb chore: fix publish workflow
All checks were successful
Lint / lint (pull_request) Successful in 15s
2026-03-26 11:57:43 +01:00
e28443ce45 chore: bump to version v1.2.0 2026-03-26 10:51:33 +00:00
5707abf6bb Merge pull request 'feat: add specs generator & fix body params' (#49) from feat/add-specs into main
Some checks failed
Publish / publish (push) Failing after 30s
Reviewed-on: #49
Reviewed-by: blas <me@blassanto.me>
2026-03-26 10:33:07 +00:00
didavila
d47afb6ff1 fix: body param and 2xx response codes in repository generation
All checks were successful
Lint / lint (pull_request) Successful in 31s
Bug 1 - Body as positional argument (api.repository.impl.mustache):
MRepository HTTP methods accept a single RequestOptions object as second
argument. The template was incorrectly passing body as a separate positional
argument (e.g. this.post('/url', body)), causing:
  'Type X has no properties in common with type RequestOptions'
Fix: merge body into the options object using ES6 shorthand { body }, and
introduce hasOptions / hasBothParamsAndBody flags to build a single unified
options literal covering all scenarios:
  - no options    → this.post('/url')
  - params only   → this.get('/url', { params: { search } })
  - body only     → this.post('/url', { body })
  - params + body → this.post('/url', { params: { search }, body })

Bug 2 - Only 200 responses read (clean-arch.generator.ts):
The generator was hardcoded to read op.responses['200'], silently ignoring
201 Created, 202 Accepted, etc. POST endpoints returning 201 were generated
as Observable<void> instead of their actual return type.
Fix: resolve the first available success code from [200, 201, 202, 203].

New fields added to TagOperation type:
  - uppercaseHttpMethod: string
  - hasOptions: boolean
  - hasBothParamsAndBody: boolean

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 11:17:37 +01:00
didavila
463626da0c feat: add .spec.ts generation for models, mappers, repositories and use-cases
Add 4 new Mustache templates for generating unit test specs:
- model-entity.spec.mustache: tests instantiation, property setting, mock builder
- mapper.spec.mustache: tests per-property DTO→Entity mapping, instanceof, all fields
- api.repository.impl.spec.mustache: tests HTTP method, response mapping, error propagation
- api.use-cases.impl.spec.mustache: tests repository delegation, observable forwarding

Generator changes:
- Add uppercaseHttpMethod to TagOperation for spec HTTP assertions
- Add testValue to TagOperationParam for auto-generated test arguments
- Add resolveTestParamValue utility for primitive/complex type test literals
- Add specs counter to GeneratedCount and GenerationReport
- Wire 4 new renderTemplate calls in schema and tag loops
- Update report generator to count .spec.ts files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 10:52:58 +01:00
05a58c4254 chore: bump to version v1.1.0 2026-03-25 10:52:48 +00:00
11 changed files with 366 additions and 21 deletions

View File

@@ -64,10 +64,12 @@ jobs:
- name: Configure Gitea registry auth
run: |
echo "//git.blassanto.me/api/packages/blas/npm/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.bunfig.toml
echo '[install.scopes]' >> ~/.bunfig.toml
echo '"@blas" = { registry = "https://git.blassanto.me/api/packages/blas/npm/", token = "${NODE_AUTH_TOKEN}" }' >> ~/.bunfig.toml
echo "@blas:registry=https://git.blassanto.me/api/packages/blas/npm/" >> ~/.npmrc
echo "//git.blassanto.me/api/packages/blas/npm/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
cat >> ~/.bunfig.toml << EOF
[install.scopes]
"@blas" = { registry = "https://git.blassanto.me/api/packages/blas/npm/", token = "${NODE_AUTH_TOKEN}" }
EOF
env:
NODE_AUTH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}

View File

@@ -1,6 +1,6 @@
{
"name": "@blas/openapi-clean-arch-generator",
"version": "1.0.1",
"version": "1.2.0",
"description": "Angular Clean Architecture generator from OpenAPI/Swagger",
"main": "dist/main.js",
"bin": {

View File

@@ -74,7 +74,8 @@ export function generateCleanArchitecture(
mappers: 0,
useCases: 0,
providers: 0,
mocks: 0
mocks: 0,
specs: 0
};
const schemas =
@@ -210,6 +211,26 @@ export function generateCleanArchitecture(
generatedCount,
'mocks'
);
// Model spec
renderTemplate(
templatesDir,
'model-entity.spec.mustache',
modelViewData,
path.join(outputDir, 'entities/models', `${toCamelCase(baseName)}.model.spec.ts`),
generatedCount,
'specs'
);
// Mapper spec
renderTemplate(
templatesDir,
'mapper.spec.mustache',
mapperViewData,
path.join(outputDir, 'data/mappers', `${toCamelCase(baseName)}.mapper.spec.ts`),
generatedCount,
'specs'
);
});
// 2. Generate Use Cases and Repositories from Paths/Tags
@@ -227,7 +248,8 @@ export function generateCleanArchitecture(
paramName: p.name,
dataType: mapSwaggerTypeToTs(p.schema?.type || ''),
description: p.description || '',
required: p.required
required: p.required,
testValue: resolveTestParamValue(mapSwaggerTypeToTs(p.schema?.type || ''))
}));
if (op.requestBody) {
@@ -241,14 +263,19 @@ export function generateCleanArchitecture(
paramName: 'body',
dataType: bodyType,
description: op.requestBody.description || '',
required: true
required: true,
testValue: resolveTestParamValue(bodyType)
});
}
let returnType = 'void';
let returnBaseType = 'void';
let isListContainer = false;
const responseSchema = op.responses?.['200']?.content?.['application/json']?.schema;
const successCode = ['200', '201', '202', '203'].find((code) => op.responses?.[code]);
const responseSchema =
successCode !== undefined
? op.responses?.[successCode]?.content?.['application/json']?.schema
: undefined;
if (responseSchema) {
if (responseSchema.$ref) {
returnType = responseSchema.$ref.split('/').pop() || 'unknown';
@@ -260,25 +287,31 @@ export function generateCleanArchitecture(
}
}
const hasQueryParams = (op.parameters || []).some((p) => p.in === 'query');
const hasBodyParam = !!op.requestBody;
tagsMap[tag].push({
nickname: op.operationId || `${method}${pathKey.replace(/\//g, '_')}`,
summary: op.summary || '',
notes: op.description || '',
httpMethod: method.toLowerCase(),
uppercaseHttpMethod: method.toUpperCase(),
path: pathKey,
allParams: allParams.map((p, i: number) => ({
...p,
'-last': i === allParams.length - 1
})),
hasQueryParams: (op.parameters || []).some((p) => p.in === 'query'),
hasQueryParams,
queryParams: (op.parameters || [])
.filter((p) => p.in === 'query')
.map((p, i: number, arr: unknown[]) => ({
paramName: p.name,
'-last': i === arr.length - 1
})),
hasBodyParam: !!op.requestBody,
hasBodyParam,
bodyParam: 'body',
hasOptions: hasQueryParams || hasBodyParam,
hasBothParamsAndBody: hasQueryParams && hasBodyParam,
returnType: returnType !== 'void' ? returnType : false,
returnBaseType: returnBaseType !== 'void' ? returnBaseType : false,
returnTypeVarName: returnType !== 'void' ? toCamelCase(returnType) : false,
@@ -438,10 +471,30 @@ export function generateCleanArchitecture(
generatedCount,
'mocks'
);
// Repository impl spec
renderTemplate(
templatesDir,
'api.repository.impl.spec.mustache',
apiViewData,
path.join(outputDir, 'data/repositories', `${toCamelCase(tag)}.repository.impl.spec.ts`),
generatedCount,
'specs'
);
// Use-cases impl spec
renderTemplate(
templatesDir,
'api.use-cases.impl.spec.mustache',
apiViewData,
path.join(outputDir, 'domain/use-cases', `${toCamelCase(tag)}.use-cases.impl.spec.ts`),
generatedCount,
'specs'
);
});
logSuccess(
`${generatedCount.models} Models, ${generatedCount.repositories} Repos, ${generatedCount.useCases} Use Cases, ${generatedCount.mappers} Mappers, ${generatedCount.providers} Providers, ${generatedCount.mocks} Mocks generated`
`${generatedCount.models} Models, ${generatedCount.repositories} Repos, ${generatedCount.useCases} Use Cases, ${generatedCount.mappers} Mappers, ${generatedCount.providers} Providers, ${generatedCount.mocks} Mocks, ${generatedCount.specs} Specs generated`
);
return generatedCount;
}
@@ -463,3 +516,18 @@ function renderTemplate(
counter[key]++;
}
}
/** Resolves a simple test value literal for a given TypeScript type. */
function resolveTestParamValue(dataType: string): string {
switch (dataType) {
case 'string':
return "'test'";
case 'number':
return '1';
case 'boolean':
return 'true';
default:
if (dataType.endsWith('[]')) return '[]';
return '{} as any';
}
}

View File

@@ -12,6 +12,15 @@ function countMockFiles(dir: string): number {
}
}
/** Counts files ending with `.spec.ts` in a directory (returns 0 if directory does not exist). */
function countSpecFiles(dir: string): number {
try {
return fs.readdirSync(dir).filter((f) => f.endsWith('.spec.ts')).length;
} catch {
return 0;
}
}
/** Generates and persists the `generation-report.json` file with process statistics. */
export function generateReport(outputDir: string, analysis: SwaggerAnalysis): GenerationReport {
logStep('Generating report...');
@@ -35,7 +44,12 @@ export function generateReport(outputDir: string, analysis: SwaggerAnalysis): Ge
countMockFiles(path.join(outputDir, 'di/repositories')) +
countMockFiles(path.join(outputDir, 'di/use-cases')) +
countMockFiles(path.join(outputDir, 'domain/use-cases')) +
countMockFiles(path.join(outputDir, 'entities/models'))
countMockFiles(path.join(outputDir, 'entities/models')),
specs:
countSpecFiles(path.join(outputDir, 'entities/models')) +
countSpecFiles(path.join(outputDir, 'data/mappers')) +
countSpecFiles(path.join(outputDir, 'data/repositories')) +
countSpecFiles(path.join(outputDir, 'domain/use-cases'))
}
};

View File

@@ -8,6 +8,7 @@ export interface GeneratedCount {
useCases: number;
providers: number;
mocks: number;
specs: number;
}
/**
@@ -25,5 +26,6 @@ export interface GenerationReport {
useCases: number;
providers: number;
mocks: number;
specs: number;
};
}

View File

@@ -96,6 +96,7 @@ export interface TagOperationParam {
description: string;
required: boolean;
'-last': boolean;
testValue?: string;
}
/**
@@ -107,12 +108,15 @@ export interface TagOperation {
summary: string;
notes: string;
httpMethod: string;
uppercaseHttpMethod: string;
path: string;
allParams: TagOperationParam[];
hasQueryParams: boolean;
queryParams: unknown[];
hasBodyParam: boolean;
bodyParam: string;
hasOptions: boolean;
hasBothParamsAndBody: boolean;
returnType: string | boolean;
returnBaseType: string | boolean;
returnTypeVarName: string | boolean;

View File

@@ -32,26 +32,20 @@ export class {{classname}}RepositoryImpl extends MRepository implements {{classn
{{#operation}}
{{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}{{^-last}}, {{/-last}}{{/allParams}}): Observable<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}> {
{{#isListContainer}}
return this.{{httpMethod}}<{{{returnBaseType}}}Dto>('{{path}}'{{#hasQueryParams}}, {
params: { {{#queryParams}}{{paramName}}{{^-last}}, {{/-last}}{{/queryParams}} }
}{{/hasQueryParams}}{{#hasBodyParam}}, {{bodyParam}}{{/hasBodyParam}})
return this.{{httpMethod}}<{{{returnBaseType}}}Dto>('{{path}}'{{#hasOptions}}, { {{#hasQueryParams}}params: { {{#queryParams}}{{paramName}}{{^-last}}, {{/-last}}{{/queryParams}} }{{/hasQueryParams}}{{#hasBothParamsAndBody}}, {{/hasBothParamsAndBody}}{{#hasBodyParam}}body{{/hasBodyParam}} }{{/hasOptions}})
.pipe(
map((response) => response.{{#vendorExtensions}}{{x-response-property}}{{/vendorExtensions}}{{^vendorExtensions}}items{{/vendorExtensions}}.map({{{returnBaseTypeVarName}}}Mapper))
);
{{/isListContainer}}
{{^isListContainer}}
{{#returnType}}
return this.{{httpMethod}}<{{{returnType}}}Dto>('{{path}}'{{#hasQueryParams}}, {
params: { {{#queryParams}}{{paramName}}{{^-last}}, {{/-last}}{{/queryParams}} }
}{{/hasQueryParams}}{{#hasBodyParam}}, {{bodyParam}}{{/hasBodyParam}})
return this.{{httpMethod}}<{{{returnType}}}Dto>('{{path}}'{{#hasOptions}}, { {{#hasQueryParams}}params: { {{#queryParams}}{{paramName}}{{^-last}}, {{/-last}}{{/queryParams}} }{{/hasQueryParams}}{{#hasBothParamsAndBody}}, {{/hasBothParamsAndBody}}{{#hasBodyParam}}body{{/hasBodyParam}} }{{/hasOptions}})
.pipe(
map({{{returnTypeVarName}}}Mapper)
);
{{/returnType}}
{{^returnType}}
return this.{{httpMethod}}<void>('{{path}}'{{#hasQueryParams}}, {
params: { {{#queryParams}}{{paramName}}{{^-last}}, {{/-last}}{{/queryParams}} }
}{{/hasQueryParams}}{{#hasBodyParam}}, {{bodyParam}}{{/hasBodyParam}});
return this.{{httpMethod}}<void>('{{path}}'{{#hasOptions}}, { {{#hasQueryParams}}params: { {{#queryParams}}{{paramName}}{{^-last}}, {{/-last}}{{/queryParams}} }{{/hasQueryParams}}{{#hasBothParamsAndBody}}, {{/hasBothParamsAndBody}}{{#hasBodyParam}}body{{/hasBodyParam}} }{{/hasOptions}});
{{/returnType}}
{{/isListContainer}}
}

View File

@@ -0,0 +1,97 @@
{{#apiInfo}}
{{#apis}}
{{#operations}}
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { {{classname}}RepositoryImpl } from './{{classFilename}}.repository.impl';
{{#returnImports}}
import { mock{{classname}}Dto } from '@/dtos/{{classFilename}}.dto.mock';
import { mock{{classname}}Model } from '@/entities/models/{{classFilename}}.model.mock';
{{/returnImports}}
describe('{{classname}}RepositoryImpl', () => {
let repository: {{classname}}RepositoryImpl;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [{{classname}}RepositoryImpl]
});
repository = TestBed.inject({{classname}}RepositoryImpl);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should be created', () => {
expect(repository).toBeTruthy();
});
{{#operation}}
describe('{{nickname}}', () => {
it('should perform a {{uppercaseHttpMethod}} request', () => {
repository.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe();
const req = httpMock.expectOne((r) => r.method === '{{uppercaseHttpMethod}}');
expect(req.request.method).toBe('{{uppercaseHttpMethod}}');
{{#isListContainer}}
req.flush({ items: [mock{{returnBaseType}}Dto()] });
{{/isListContainer}}
{{^isListContainer}}
{{#returnType}}
req.flush(mock{{returnBaseType}}Dto());
{{/returnType}}
{{^returnType}}
req.flush(null);
{{/returnType}}
{{/isListContainer}}
});
{{#returnType}}
it('should map the response to the domain model', () => {
const dto = mock{{returnBaseType}}Dto();
const expectedModel = mock{{returnBaseType}}Model();
{{#isListContainer}}
repository.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe((result) => {
expect(result).toBeTruthy();
expect(result.length).toBeGreaterThan(0);
});
httpMock.expectOne((r) => r.method === '{{uppercaseHttpMethod}}').flush({ items: [dto] });
{{/isListContainer}}
{{^isListContainer}}
repository.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe((result) => {
expect(result).toEqual(expectedModel);
});
httpMock.expectOne((r) => r.method === '{{uppercaseHttpMethod}}').flush(dto);
{{/isListContainer}}
});
{{/returnType}}
it('should propagate HTTP errors', (done) => {
repository.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe({
error: (err) => {
expect(err.status).toBe(500);
done();
}
});
httpMock
.expectOne((r) => r.method === '{{uppercaseHttpMethod}}')
.flush('Internal Server Error', { status: 500, statusText: 'Internal Server Error' });
});
});
{{/operation}}
});
{{/operations}}
{{/apis}}
{{/apiInfo}}

View File

@@ -0,0 +1,91 @@
{{#apiInfo}}
{{#apis}}
{{#operations}}
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { {{classname}}UseCasesImpl } from './{{classFilename}}.use-cases.impl';
import { {{constantName}}_REPOSITORY, {{classname}}Repository } from '@/domain/repositories/{{classFilename}}.repository.contract';
{{#returnImports}}
import { mock{{classname}}Model } from '@/entities/models/{{classFilename}}.model.mock';
{{/returnImports}}
describe('{{classname}}UseCasesImpl', () => {
let useCase: {{classname}}UseCasesImpl;
let mockRepository: jasmine.SpyObj<{{classname}}Repository>;
beforeEach(() => {
mockRepository = jasmine.createSpyObj('{{classname}}Repository', [{{#operation}}'{{nickname}}', {{/operation}}]);
TestBed.configureTestingModule({
providers: [
{{classname}}UseCasesImpl,
{ provide: {{constantName}}_REPOSITORY, useValue: mockRepository }
]
});
useCase = TestBed.inject({{classname}}UseCasesImpl);
});
it('should be created', () => {
expect(useCase).toBeTruthy();
});
{{#operation}}
describe('{{nickname}}', () => {
it('should delegate to the repository', () => {
{{#isListContainer}}
mockRepository.{{nickname}}.and.returnValue(of([mock{{returnBaseType}}Model()]));
{{/isListContainer}}
{{^isListContainer}}
{{#returnBaseType}}
mockRepository.{{nickname}}.and.returnValue(of(mock{{returnBaseType}}Model()));
{{/returnBaseType}}
{{^returnBaseType}}
mockRepository.{{nickname}}.and.returnValue(of(undefined));
{{/returnBaseType}}
{{/isListContainer}}
useCase.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}});
expect(mockRepository.{{nickname}}).toHaveBeenCalled();
});
it('should return the observable from the repository', (done) => {
{{#isListContainer}}
const expected = [mock{{returnBaseType}}Model()];
mockRepository.{{nickname}}.and.returnValue(of(expected));
useCase.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe((result) => {
expect(result).toEqual(expected);
done();
});
{{/isListContainer}}
{{^isListContainer}}
{{#returnBaseType}}
const expected = mock{{returnBaseType}}Model();
mockRepository.{{nickname}}.and.returnValue(of(expected));
useCase.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe((result) => {
expect(result).toEqual(expected);
done();
});
{{/returnBaseType}}
{{^returnBaseType}}
mockRepository.{{nickname}}.and.returnValue(of(undefined));
useCase.{{nickname}}({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}}).subscribe({
complete: () => done()
});
{{/returnBaseType}}
{{/isListContainer}}
});
});
{{/operation}}
});
{{/operations}}
{{/apis}}
{{/apiInfo}}

View File

@@ -0,0 +1,39 @@
{{#models}}
{{#model}}
import { {{classVarName}}Mapper } from './{{classFilename}}.mapper';
import { mock{{classname}}Dto } from '@/dtos/{{classFilename}}.dto.mock';
import { {{classname}} } from '@/entities/models/{{classFilename}}.model';
describe('{{classVarName}}Mapper', () => {
{{#vars}}
it('should map {{name}} from DTO to model', () => {
const dto = mock{{classname}}Dto();
const result = {{classVarName}}Mapper(dto);
expect(result.{{name}}).toBe(dto.{{name}});
});
{{/vars}}
it('should return an instance of {{classname}}', () => {
const dto = mock{{classname}}Dto();
const result = {{classVarName}}Mapper(dto);
expect(result).toBeInstanceOf({{classname}});
});
it('should map all fields correctly from a complete DTO', () => {
const dto = mock{{classname}}Dto();
const result = {{classVarName}}Mapper(dto);
{{#vars}}
expect(result.{{name}}).toBe(dto.{{name}});
{{/vars}}
});
});
{{/model}}
{{/models}}

View File

@@ -0,0 +1,34 @@
{{#models}}
{{#model}}
import { {{classname}} } from './{{classFilename}}.model';
import { mock{{classname}}Model } from './{{classFilename}}.model.mock';
describe('{{classname}}', () => {
it('should create an instance', () => {
const model = new {{classname}}();
expect(model).toBeInstanceOf({{classname}});
});
{{#vars}}
it('should allow setting {{name}}', () => {
const model = new {{classname}}();
const expected = mock{{classname}}Model();
model.{{name}} = expected.{{name}};
expect(model.{{name}}).toBe(expected.{{name}});
});
{{/vars}}
it('should build a valid model from mock', () => {
const model = mock{{classname}}Model();
expect(model).toBeInstanceOf({{classname}});
{{#vars}}
expect(model.{{name}}).toBeDefined();
{{/vars}}
});
});
{{/model}}
{{/models}}