Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 902c0fd5a2 | |||
| ef804b74c2 | |||
| 98cd7a5edb | |||
| 3ede53ae3b | |||
| aa7c6cf338 | |||
| 0075717a1f | |||
| 7c5af2f3ab | |||
| e667c7bf04 | |||
| 2e9aecdffe | |||
| 5304ed7047 | |||
| 56bd6f4e9f | |||
| cbef98a077 | |||
| 3eb604c95f | |||
| 4293857a7d | |||
| 07e4559133 | |||
| bdd0e4873e | |||
| ddca01e4e9 | |||
| 59ff941fda | |||
| 8881e9494c | |||
| 720748b73d | |||
| 7063796e28 | |||
| f349b7b2a3 | |||
| b59084dec6 | |||
| 5c83520f01 | |||
| cc0439e26e | |||
| b5b3632f5b | |||
| d78bc303fa | |||
| df9283556b | |||
| 909f709659 | |||
| 5878331abf | |||
| 7e8e6d7058 | |||
| 469697f636 | |||
| 2257e2141e | |||
| 0f64b51b63 | |||
| 9c385191e2 | |||
| d2f9eaa933 | |||
| a600a60678 | |||
| a42063c1d9 | |||
| 7f6feda81d | |||
| 12b2dd6b51 | |||
| 84486e816a | |||
| 942cf7f092 | |||
| e0fb12a6c6 | |||
| 2402b40059 | |||
| 04962e32f5 | |||
| 144629bed6 | |||
| 0c58a63d01 | |||
| 74ac1c26a1 | |||
| db70f47bb7 | |||
| 91e608415f | |||
| 058abf59c4 | |||
| 1d52da3805 | |||
| 32cb3d476f | |||
| 79ea7dfc7e | |||
| b54a94c6d3 | |||
| 77e3cbc0e9 | |||
| 16d4c8e0bb | |||
| e28443ce45 | |||
| 5707abf6bb | |||
|
|
d47afb6ff1 | ||
|
|
463626da0c | ||
| 05a58c4254 |
@@ -62,19 +62,17 @@ jobs:
|
|||||||
- name: Build binaries
|
- name: Build binaries
|
||||||
run: bun run binaries
|
run: bun run binaries
|
||||||
|
|
||||||
- name: Configure Gitea registry auth
|
- name: Configure npm registry auth
|
||||||
run: |
|
run: |
|
||||||
echo "//git.blassanto.me/api/packages/blas/npm/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.bunfig.toml
|
echo "registry=https://registry.npmjs.org" >> ~/.npmrc
|
||||||
echo '[install.scopes]' >> ~/.bunfig.toml
|
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
|
||||||
echo '"@blas" = { registry = "https://git.blassanto.me/api/packages/blas/npm/", token = "${NODE_AUTH_TOKEN}" }' >> ~/.bunfig.toml
|
|
||||||
echo "//git.blassanto.me/api/packages/blas/npm/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
|
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
- name: Publish to Gitea registry
|
- name: Publish to npm registry
|
||||||
run: bun publish --access public
|
run: bun publish --access public
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
|
BUN_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
- name: Create Gitea release and upload binaries
|
- name: Create Gitea release and upload binaries
|
||||||
run: |
|
run: |
|
||||||
@@ -87,7 +85,7 @@ jobs:
|
|||||||
-d "{
|
-d "{
|
||||||
\"tag_name\": \"${GITHUB_REF_NAME}\",
|
\"tag_name\": \"${GITHUB_REF_NAME}\",
|
||||||
\"name\": \"v${VERSION}\",
|
\"name\": \"v${VERSION}\",
|
||||||
\"body\": \"## Installation\n\nDownload the binary for your platform or install via the npm registry:\n\n\`\`\`bash\nbun add -g @blas/openapi-clean-arch-generator --registry https://git.blassanto.me/api/packages/blas/npm/\n\`\`\`\",
|
\"body\": \"## Installation\n\nDownload the binary for your platform or install via the npm registry:\n\n\`\`\`bash\nbun add -g @0kmpo/openapi-clean-arch-generator\n\`\`\`\",
|
||||||
\"draft\": false,
|
\"draft\": false,
|
||||||
\"prerelease\": false
|
\"prerelease\": false
|
||||||
}" | bun -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).id))")
|
}" | bun -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).id))")
|
||||||
|
|||||||
50
NOTICE
Normal file
50
NOTICE
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
OpenAPI Clean Architecture Generator
|
||||||
|
Copyright (c) 2026 Blas Santomé Ocampo
|
||||||
|
|
||||||
|
This product includes software developed by third parties.
|
||||||
|
All third-party packages listed below are distributed under the MIT License.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
chalk v4.1.2
|
||||||
|
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
||||||
|
License: MIT
|
||||||
|
https://github.com/chalk/chalk
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
commander v11.1.0
|
||||||
|
Copyright (c) TJ Holowaychuk <tj@vision-media.ca>
|
||||||
|
License: MIT
|
||||||
|
https://github.com/tj/commander.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
fs-extra v11.3.4
|
||||||
|
Copyright (c) JP Richardson <jprichardson@gmail.com>
|
||||||
|
License: MIT
|
||||||
|
https://github.com/jprichardson/node-fs-extra
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
js-yaml v4.1.1
|
||||||
|
Copyright (c) Vladimir Zapparov <dervus.grim@gmail.com>
|
||||||
|
Copyright (c) Aleksey V Zapparov <ixti@member.fsf.org>
|
||||||
|
Copyright (c) Vitaly Puzrin <vitaly@rcdesign.ru>
|
||||||
|
Copyright (c) Martin Grenfell <martin.grenfell@gmail.com>
|
||||||
|
License: MIT
|
||||||
|
https://github.com/nodeca/js-yaml
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
mustache v4.2.0
|
||||||
|
Copyright (c) mustache.js Authors (http://github.com/janl/mustache.js)
|
||||||
|
License: MIT
|
||||||
|
https://github.com/janl/mustache.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
prompts v2.4.2
|
||||||
|
Copyright (c) Terkel Gjervig
|
||||||
|
License: MIT
|
||||||
|
https://github.com/terkelg/prompts
|
||||||
97
README.md
97
README.md
@@ -15,29 +15,29 @@ Download the binary for your platform from the releases page and run it directly
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS (Apple Silicon)
|
# macOS (Apple Silicon)
|
||||||
curl -L <release-url>/generate-clean-arch-macos-arm64 -o generate-clean-arch
|
curl -L https://git.blassanto.me/blas/openapi-clean-arch-gen/releases/latest/download/generate-clean-arch-macos-arm64 -o generate-clean-arch
|
||||||
chmod +x generate-clean-arch && ./generate-clean-arch -i swagger.yaml
|
chmod +x generate-clean-arch && ./generate-clean-arch -i swagger.yaml
|
||||||
|
|
||||||
# macOS (Intel)
|
# macOS (Intel)
|
||||||
curl -L <release-url>/generate-clean-arch-macos-x64 -o generate-clean-arch
|
curl -L https://git.blassanto.me/blas/openapi-clean-arch-gen/releases/latest/download/generate-clean-arch-macos-x64 -o generate-clean-arch
|
||||||
chmod +x generate-clean-arch && ./generate-clean-arch -i swagger.yaml
|
chmod +x generate-clean-arch && ./generate-clean-arch -i swagger.yaml
|
||||||
|
|
||||||
# Linux x64
|
# Linux x64
|
||||||
curl -L <release-url>/generate-clean-arch-linux-x64 -o generate-clean-arch
|
curl -L https://git.blassanto.me/blas/openapi-clean-arch-gen/releases/latest/download/generate-clean-arch-linux-x64 -o generate-clean-arch
|
||||||
chmod +x generate-clean-arch && ./generate-clean-arch -i swagger.yaml
|
chmod +x generate-clean-arch && ./generate-clean-arch -i swagger.yaml
|
||||||
|
|
||||||
# Windows (PowerShell)
|
# Windows (PowerShell)
|
||||||
curl -L <release-url>/generate-clean-arch-windows-x64.exe -o generate-clean-arch.exe
|
curl -L https://git.blassanto.me/blas/openapi-clean-arch-gen/releases/latest/download/generate-clean-arch-windows-x64.exe -o generate-clean-arch.exe
|
||||||
.\generate-clean-arch.exe -i swagger.yaml
|
.\generate-clean-arch.exe -i swagger.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 1: Install as a global CLI from the registry
|
### Option 1: Install as a global CLI from npm
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun add -g @blas/openapi-clean-arch-generator --registry https://git.blassanto.me/api/packages/blas/npm/
|
bun add -g @0kmpo/openapi-clean-arch-generator
|
||||||
```
|
```
|
||||||
|
|
||||||
Or configure the registry in your `.npmrc` / `bunfig.toml` and then run:
|
Then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
generate-clean-arch -i swagger.yaml
|
generate-clean-arch -i swagger.yaml
|
||||||
@@ -76,7 +76,10 @@ Options:
|
|||||||
-o, --output <dir> Output directory [default: ./src/app]
|
-o, --output <dir> Output directory [default: ./src/app]
|
||||||
-t, --templates <dir> Custom templates directory [default: ./templates]
|
-t, --templates <dir> Custom templates directory [default: ./templates]
|
||||||
-s, --select-endpoints Interactively select tags and endpoints to generate
|
-s, --select-endpoints Interactively select tags and endpoints to generate
|
||||||
|
-c, --config <file> Use a JSON configuration file (skips interactive prompts)
|
||||||
|
--init-config [file] Generate a JSON configuration file instead of generating code
|
||||||
--skip-install Skip dependency installation
|
--skip-install Skip dependency installation
|
||||||
|
--skip-lint Skip post-generation linting and formatting
|
||||||
--dry-run Simulate without writing files
|
--dry-run Simulate without writing files
|
||||||
-h, --help Show help
|
-h, --help Show help
|
||||||
```
|
```
|
||||||
@@ -96,6 +99,15 @@ generate-clean-arch -i api.yaml -t ./my-templates
|
|||||||
# Dry run (no files written)
|
# Dry run (no files written)
|
||||||
generate-clean-arch -i swagger.yaml --dry-run
|
generate-clean-arch -i swagger.yaml --dry-run
|
||||||
|
|
||||||
|
# Skip linting after generation
|
||||||
|
generate-clean-arch -i swagger.yaml --skip-lint
|
||||||
|
|
||||||
|
# Generate a config file to reuse later
|
||||||
|
generate-clean-arch --init-config generation-config.json
|
||||||
|
|
||||||
|
# Run using a config file (no interactive prompts)
|
||||||
|
generate-clean-arch -c generation-config.json
|
||||||
|
|
||||||
# Full example with all options
|
# Full example with all options
|
||||||
generate-clean-arch -i ./docs/api.yaml -o ./frontend/src/app -t ./custom-templates
|
generate-clean-arch -i ./docs/api.yaml -o ./frontend/src/app -t ./custom-templates
|
||||||
```
|
```
|
||||||
@@ -109,48 +121,54 @@ src/app/
|
|||||||
├── data/ # Data layer
|
├── data/ # Data layer
|
||||||
│ ├── dtos/ # Data Transfer Objects
|
│ ├── dtos/ # Data Transfer Objects
|
||||||
│ │ ├── node/
|
│ │ ├── node/
|
||||||
│ │ │ └── node.dto.ts
|
│ │ │ ├── node.dto.ts
|
||||||
|
│ │ │ └── node.dto.mock.ts
|
||||||
│ │ ├── order-type/
|
│ │ ├── order-type/
|
||||||
│ │ │ └── order-type.dto.ts
|
│ │ │ ├── order-type.dto.ts
|
||||||
|
│ │ │ └── order-type.dto.mock.ts
|
||||||
│ │ └── supply-mode/
|
│ │ └── supply-mode/
|
||||||
│ │ └── supply-mode.dto.ts
|
│ │ ├── supply-mode.dto.ts
|
||||||
|
│ │ └── supply-mode.dto.mock.ts
|
||||||
│ ├── repositories/ # Repository implementations
|
│ ├── repositories/ # Repository implementations
|
||||||
│ │ ├── node.repository.impl.ts
|
│ │ ├── node.repository.impl.ts
|
||||||
|
│ │ ├── node.repository.impl.mock.ts
|
||||||
|
│ │ ├── node.repository.impl.spec.ts
|
||||||
│ │ ├── order-type.repository.impl.ts
|
│ │ ├── order-type.repository.impl.ts
|
||||||
│ │ └── supply-mode.repository.impl.ts
|
│ │ ├── order-type.repository.impl.mock.ts
|
||||||
|
│ │ ├── order-type.repository.impl.spec.ts
|
||||||
|
│ │ └── ...
|
||||||
│ └── mappers/ # DTO → Entity transformers
|
│ └── mappers/ # DTO → Entity transformers
|
||||||
│ ├── node.mapper.ts
|
│ ├── node.mapper.ts
|
||||||
|
│ ├── node.mapper.spec.ts
|
||||||
│ ├── order-type.mapper.ts
|
│ ├── order-type.mapper.ts
|
||||||
│ └── supply-mode.mapper.ts
|
│ ├── order-type.mapper.spec.ts
|
||||||
|
│ └── ...
|
||||||
├── domain/ # Domain layer
|
├── domain/ # Domain layer
|
||||||
│ ├── repositories/ # Repository contracts
|
│ ├── repositories/ # Repository contracts
|
||||||
│ │ ├── node.repository.contract.ts
|
│ │ ├── node.repository.contract.ts
|
||||||
│ │ ├── order-type.repository.contract.ts
|
│ │ └── ...
|
||||||
│ │ └── supply-mode.repository.contract.ts
|
|
||||||
│ └── use-cases/ # Use cases
|
│ └── use-cases/ # Use cases
|
||||||
│ ├── node/
|
│ ├── node/
|
||||||
│ │ ├── node.use-cases.contract.ts
|
│ │ ├── node.use-cases.contract.ts
|
||||||
│ │ └── node.use-cases.impl.ts
|
│ │ ├── node.use-cases.impl.ts
|
||||||
│ ├── order-type/
|
│ │ ├── node.use-cases.mock.ts
|
||||||
│ │ ├── order-type.use-cases.contract.ts
|
│ │ └── node.use-cases.impl.spec.ts
|
||||||
│ │ └── order-type.use-cases.impl.ts
|
│ └── ...
|
||||||
│ └── supply-mode/
|
|
||||||
│ ├── supply-mode.use-cases.contract.ts
|
|
||||||
│ └── supply-mode.use-cases.impl.ts
|
|
||||||
├── di/ # Dependency injection
|
├── di/ # Dependency injection
|
||||||
│ ├── repositories/ # Repository providers
|
│ ├── repositories/ # Repository providers
|
||||||
│ │ ├── node.repository.provider.ts
|
│ │ ├── node.repository.provider.ts
|
||||||
│ │ ├── order-type.repository.provider.ts
|
│ │ ├── node.repository.provider.mock.ts
|
||||||
│ │ └── supply-mode.repository.provider.ts
|
│ │ └── ...
|
||||||
│ └── use-cases/ # Use case providers
|
│ └── use-cases/ # Use case providers
|
||||||
│ ├── node.use-cases.provider.ts
|
│ ├── node.use-cases.provider.ts
|
||||||
│ ├── order-type.use-cases.provider.ts
|
│ ├── node.use-cases.provider.mock.ts
|
||||||
│ └── supply-mode.use-cases.provider.ts
|
│ └── ...
|
||||||
└── entities/ # Domain entities
|
└── entities/ # Domain entities
|
||||||
└── models/
|
└── models/
|
||||||
├── node.model.ts
|
├── node.model.ts
|
||||||
├── order-type.model.ts
|
├── node.model.mock.ts
|
||||||
└── supply-mode.model.ts
|
├── node.model.spec.ts
|
||||||
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Template Customization
|
## 🔧 Template Customization
|
||||||
@@ -160,14 +178,24 @@ Templates live in `templates/` and use [Mustache](https://mustache.github.io/) s
|
|||||||
| Template | Generates |
|
| Template | Generates |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `model.mustache` | DTOs |
|
| `model.mustache` | DTOs |
|
||||||
|
| `model.mock.mustache` | DTO mocks |
|
||||||
|
| `dto.mock.mustache` | DTO mocks (alternative) |
|
||||||
| `model-entity.mustache` | Domain entity models |
|
| `model-entity.mustache` | Domain entity models |
|
||||||
|
| `model-entity.spec.mustache` | Entity model specs |
|
||||||
| `mapper.mustache` | DTO → Entity mappers |
|
| `mapper.mustache` | DTO → Entity mappers |
|
||||||
|
| `mapper.spec.mustache` | Mapper specs |
|
||||||
| `api.repository.contract.mustache` | Repository contracts |
|
| `api.repository.contract.mustache` | Repository contracts |
|
||||||
| `api.repository.impl.mustache` | Repository implementations |
|
| `api.repository.impl.mustache` | Repository implementations |
|
||||||
|
| `api.repository.impl.mock.mustache` | Repository mocks |
|
||||||
|
| `api.repository.impl.spec.mustache` | Repository specs |
|
||||||
| `api.use-cases.contract.mustache` | Use case contracts |
|
| `api.use-cases.contract.mustache` | Use case contracts |
|
||||||
| `api.use-cases.impl.mustache` | Use case implementations |
|
| `api.use-cases.impl.mustache` | Use case implementations |
|
||||||
|
| `api.use-cases.mock.mustache` | Use case mocks |
|
||||||
|
| `api.use-cases.impl.spec.mustache` | Use case specs |
|
||||||
| `repository.provider.mustache` | Repository DI providers |
|
| `repository.provider.mustache` | Repository DI providers |
|
||||||
|
| `repository.provider.mock.mustache` | Repository provider mocks |
|
||||||
| `use-cases.provider.mustache` | Use case DI providers |
|
| `use-cases.provider.mustache` | Use case DI providers |
|
||||||
|
| `use-cases.provider.mock.mustache` | Use case provider mocks |
|
||||||
|
|
||||||
### Available Mustache variables
|
### Available Mustache variables
|
||||||
|
|
||||||
@@ -194,12 +222,23 @@ After each run a `generation-report.json` file is created:
|
|||||||
"timestamp": "2025-01-15T10:30:00.000Z",
|
"timestamp": "2025-01-15T10:30:00.000Z",
|
||||||
"tags": 3,
|
"tags": 3,
|
||||||
"endpoints": 8,
|
"endpoints": 8,
|
||||||
|
"tagDetails": [
|
||||||
|
{ "name": "User", "description": "User operations", "endpoints": 3 },
|
||||||
|
{ "name": "Product", "description": "Product operations", "endpoints": 2 }
|
||||||
|
],
|
||||||
"outputDirectory": "./src/app",
|
"outputDirectory": "./src/app",
|
||||||
|
"linting": {
|
||||||
|
"prettier": { "ran": true, "filesFormatted": 42 },
|
||||||
|
"eslint": { "ran": true, "filesFixed": 42 }
|
||||||
|
},
|
||||||
"structure": {
|
"structure": {
|
||||||
"dtos": 15,
|
"dtos": 15,
|
||||||
"repositories": 9,
|
"repositories": 9,
|
||||||
"mappers": 3,
|
"mappers": 5,
|
||||||
"useCases": 6
|
"useCases": 6,
|
||||||
|
"providers": 12,
|
||||||
|
"mocks": 18,
|
||||||
|
"specs": 14
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
24
generation-config.json
Normal file
24
generation-config.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"input": "example-swagger.yaml",
|
||||||
|
"output": "./src/app",
|
||||||
|
"skipLint": false,
|
||||||
|
"skipInstall": false,
|
||||||
|
"tags": {
|
||||||
|
"User": {
|
||||||
|
"baseUrl": "apiUrl",
|
||||||
|
"endpoints": [
|
||||||
|
"getUsers",
|
||||||
|
"createUser",
|
||||||
|
"getUserById",
|
||||||
|
"deleteUser"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Product": {
|
||||||
|
"baseUrl": "apiUrl",
|
||||||
|
"endpoints": [
|
||||||
|
"getProducts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": "/Users/bsantome/Downloads/openapi-clean-arch-generator/templates"
|
||||||
|
}
|
||||||
146
main.ts
146
main.ts
@@ -5,7 +5,15 @@ import mustache from 'mustache';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { program } from 'commander';
|
import { program } from 'commander';
|
||||||
|
|
||||||
import { log, logSuccess, logInfo, logWarning, logError, colors } from './src/utils/logger';
|
import {
|
||||||
|
log,
|
||||||
|
logSuccess,
|
||||||
|
logWarning,
|
||||||
|
logError,
|
||||||
|
logDetail,
|
||||||
|
initGenerationLog,
|
||||||
|
colors
|
||||||
|
} from './src/utils/logger';
|
||||||
import { checkOpenApiGenerator, installOpenApiGenerator } from './src/utils/openapi-generator';
|
import { checkOpenApiGenerator, installOpenApiGenerator } from './src/utils/openapi-generator';
|
||||||
import { createDirectoryStructure, cleanup } from './src/utils/filesystem';
|
import { createDirectoryStructure, cleanup } from './src/utils/filesystem';
|
||||||
import { analyzeSwagger } from './src/swagger/analyzer';
|
import { analyzeSwagger } from './src/swagger/analyzer';
|
||||||
@@ -18,7 +26,14 @@ import { generateReport } from './src/generators/report.generator';
|
|||||||
import { lintGeneratedFiles } from './src/generators/lint.generator';
|
import { lintGeneratedFiles } from './src/generators/lint.generator';
|
||||||
import { findEnvironmentFile, parseApiKeys } from './src/utils/environment-finder';
|
import { findEnvironmentFile, parseApiKeys } from './src/utils/environment-finder';
|
||||||
import { askApiKeysForTags, askSelectionFilter } from './src/utils/prompt';
|
import { askApiKeysForTags, askSelectionFilter } from './src/utils/prompt';
|
||||||
import type { SelectionFilter } from './src/types';
|
import {
|
||||||
|
loadConfig,
|
||||||
|
generateDefaultConfig,
|
||||||
|
writeConfig,
|
||||||
|
deriveSelectionFilter,
|
||||||
|
deriveTagApiKeyMap
|
||||||
|
} from './src/utils/config';
|
||||||
|
import type { SelectionFilter, LintResult } from './src/types';
|
||||||
import type { CliOptions } from './src/types';
|
import type { CliOptions } from './src/types';
|
||||||
import packageJson from './package.json';
|
import packageJson from './package.json';
|
||||||
|
|
||||||
@@ -40,6 +55,8 @@ program
|
|||||||
.option('--dry-run', 'Simulate without generating files')
|
.option('--dry-run', 'Simulate without generating files')
|
||||||
.option('--skip-lint', 'Skip post-generation linting and formatting')
|
.option('--skip-lint', 'Skip post-generation linting and formatting')
|
||||||
.option('-s, --select-endpoints', 'Interactively select which tags and endpoints to generate')
|
.option('-s, --select-endpoints', 'Interactively select which tags and endpoints to generate')
|
||||||
|
.option('-c, --config <file>', 'Use a JSON configuration file (skips interactive prompts)')
|
||||||
|
.option('--init-config [file]', 'Generate a JSON configuration file instead of generating code')
|
||||||
.parse(process.argv);
|
.parse(process.argv);
|
||||||
|
|
||||||
const options = program.opts<CliOptions>();
|
const options = program.opts<CliOptions>();
|
||||||
@@ -52,15 +69,32 @@ async function main(): Promise<void> {
|
|||||||
log(' Angular + Clean Architecture Code Generator', 'cyan');
|
log(' Angular + Clean Architecture Code Generator', 'cyan');
|
||||||
console.log('='.repeat(60) + '\n');
|
console.log('='.repeat(60) + '\n');
|
||||||
|
|
||||||
|
const logPath = path.join(process.cwd(), 'generation.log');
|
||||||
|
initGenerationLog(logPath);
|
||||||
|
|
||||||
|
// ── CONFIG FILE: override CLI defaults with config values ─────────────────
|
||||||
|
const configFile = options.config;
|
||||||
|
const generationConfig = configFile ? loadConfig(configFile) : undefined;
|
||||||
|
|
||||||
|
if (generationConfig) {
|
||||||
|
if (generationConfig.input) options.input = generationConfig.input;
|
||||||
|
if (generationConfig.output) options.output = generationConfig.output;
|
||||||
|
if (generationConfig.templates) options.templates = generationConfig.templates;
|
||||||
|
if (generationConfig.skipInstall !== undefined)
|
||||||
|
options.skipInstall = generationConfig.skipInstall;
|
||||||
|
if (generationConfig.skipLint !== undefined) options.skipLint = generationConfig.skipLint;
|
||||||
|
logDetail('config', `Using configuration file: ${configFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logDetail('config', `Input: ${options.input}`);
|
||||||
|
logDetail('config', `Output: ${options.output}`);
|
||||||
|
logDetail('config', `Templates: ${options.templates}`);
|
||||||
|
|
||||||
if (!fs.existsSync(options.input)) {
|
if (!fs.existsSync(options.input)) {
|
||||||
logError(`File not found: ${options.input}`);
|
logError(`File not found: ${options.input}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
logInfo(`Input file: ${options.input}`);
|
|
||||||
logInfo(`Output directory: ${options.output}`);
|
|
||||||
logInfo(`Templates: ${options.templates}`);
|
|
||||||
|
|
||||||
if (options.dryRun) {
|
if (options.dryRun) {
|
||||||
logWarning('DRY RUN mode — no files will be generated');
|
logWarning('DRY RUN mode — no files will be generated');
|
||||||
}
|
}
|
||||||
@@ -80,46 +114,82 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const analysis = analyzeSwagger(options.input);
|
const analysis = analyzeSwagger(options.input);
|
||||||
|
const tagSummaries = extractTagsWithOperations(analysis);
|
||||||
|
|
||||||
|
// ── INIT CONFIG MODE: generate config file and exit ───────────────────────
|
||||||
|
if (options.initConfig !== undefined) {
|
||||||
|
const envFile = findEnvironmentFile(process.cwd());
|
||||||
|
let apiKeys: ReturnType<typeof parseApiKeys> = [];
|
||||||
|
if (envFile) {
|
||||||
|
const envContent = fs.readFileSync(envFile, 'utf8');
|
||||||
|
apiKeys = parseApiKeys(envContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultConfig = generateDefaultConfig(analysis, tagSummaries, options, apiKeys);
|
||||||
|
const outputFile =
|
||||||
|
typeof options.initConfig === 'string' ? options.initConfig : 'generation-config.json';
|
||||||
|
|
||||||
|
writeConfig(defaultConfig, outputFile);
|
||||||
|
logSuccess(`Configuration file generated: ${outputFile}`);
|
||||||
|
logDetail(
|
||||||
|
'config',
|
||||||
|
'Edit the file to customise tags, endpoints and baseUrls, then run with --config'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.dryRun) {
|
if (options.dryRun) {
|
||||||
logInfo('Finishing in DRY RUN mode');
|
logWarning('Finishing in DRY RUN mode');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
createDirectoryStructure(options.output);
|
createDirectoryStructure(options.output);
|
||||||
|
|
||||||
// ── SELECTION: tags and endpoints ─────────────────────────────────────────
|
// ── SELECTION: tags and endpoints ─────────────────────────────────────────
|
||||||
const tagSummaries = extractTagsWithOperations(analysis);
|
|
||||||
let selectionFilter: SelectionFilter = {};
|
let selectionFilter: SelectionFilter = {};
|
||||||
|
let tagApiKeyMap: Record<string, string>;
|
||||||
|
|
||||||
if (options.selectEndpoints) {
|
if (generationConfig) {
|
||||||
selectionFilter = await askSelectionFilter(tagSummaries);
|
// Config-driven: derive everything from the JSON file
|
||||||
}
|
selectionFilter = deriveSelectionFilter(generationConfig);
|
||||||
|
tagApiKeyMap = deriveTagApiKeyMap(generationConfig);
|
||||||
const selectedTags = options.selectEndpoints
|
logDetail('config', `Tags from config: ${Object.keys(generationConfig.tags).join(', ')}`);
|
||||||
? Object.keys(selectionFilter)
|
Object.entries(tagApiKeyMap).forEach(([tag, key]) => {
|
||||||
: tagSummaries.map((t) => t.tag);
|
logDetail('config', `API key for "${tag}": environment.${key}.url`);
|
||||||
|
});
|
||||||
// ── ENVIRONMENT API KEY SELECTION ──────────────────────────────────────────
|
|
||||||
const envFile = findEnvironmentFile(process.cwd());
|
|
||||||
let apiKeys: ReturnType<typeof parseApiKeys> = [];
|
|
||||||
|
|
||||||
if (envFile) {
|
|
||||||
const envContent = fs.readFileSync(envFile, 'utf8');
|
|
||||||
apiKeys = parseApiKeys(envContent);
|
|
||||||
logSuccess(
|
|
||||||
`environment.ts found: ${colors.cyan}${path.relative(process.cwd(), envFile)}${colors.reset}`
|
|
||||||
);
|
|
||||||
if (apiKeys.length > 0) {
|
|
||||||
logInfo(`Detected API keys: ${apiKeys.map((k) => k.key).join(', ')}`);
|
|
||||||
} else {
|
|
||||||
logWarning('No keys containing "api" found in environment.ts. Will be requested manually.');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
logWarning('No environment.ts found. The key will be requested manually per repository.');
|
// Interactive mode (original behaviour)
|
||||||
|
if (options.selectEndpoints) {
|
||||||
|
selectionFilter = await askSelectionFilter(tagSummaries);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTags = options.selectEndpoints
|
||||||
|
? Object.keys(selectionFilter)
|
||||||
|
: tagSummaries.map((t) => t.tag);
|
||||||
|
|
||||||
|
// ── ENVIRONMENT API KEY SELECTION ────────────────────────────────────────
|
||||||
|
const envFile = findEnvironmentFile(process.cwd());
|
||||||
|
let apiKeys: ReturnType<typeof parseApiKeys> = [];
|
||||||
|
|
||||||
|
if (envFile) {
|
||||||
|
const envContent = fs.readFileSync(envFile, 'utf8');
|
||||||
|
apiKeys = parseApiKeys(envContent);
|
||||||
|
logSuccess(
|
||||||
|
`environment.ts found: ${colors.cyan}${path.relative(process.cwd(), envFile)}${colors.reset}`
|
||||||
|
);
|
||||||
|
if (apiKeys.length === 0) {
|
||||||
|
logWarning('No keys containing "api" found in environment.ts. Will be requested manually.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logWarning('No environment.ts found. The key will be requested manually per repository.');
|
||||||
|
}
|
||||||
|
|
||||||
|
tagApiKeyMap = await askApiKeysForTags(selectedTags, apiKeys);
|
||||||
|
Object.entries(tagApiKeyMap).forEach(([tag, key]) => {
|
||||||
|
logDetail('config', `API key for "${tag}": environment.${key}.url`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagApiKeyMap = await askApiKeysForTags(selectedTags, apiKeys);
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const tempDir = generateCode(options.input, options.templates);
|
const tempDir = generateCode(options.input, options.templates);
|
||||||
@@ -134,11 +204,13 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
cleanup(tempDir);
|
cleanup(tempDir);
|
||||||
|
|
||||||
if (!options.skipLint) {
|
const noLintResult: LintResult = {
|
||||||
lintGeneratedFiles(options.output);
|
prettier: { ran: false, filesFormatted: 0 },
|
||||||
}
|
eslint: { ran: false, filesFixed: 0 }
|
||||||
|
};
|
||||||
|
const lintResult = options.skipLint ? noLintResult : lintGeneratedFiles(options.output);
|
||||||
|
|
||||||
const report = generateReport(options.output, analysis);
|
const report = generateReport(options.output, analysis, lintResult);
|
||||||
|
|
||||||
console.log('\n' + '='.repeat(60));
|
console.log('\n' + '='.repeat(60));
|
||||||
log(' ✨ Generation completed successfully', 'green');
|
log(' ✨ Generation completed successfully', 'green');
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@blas/openapi-clean-arch-generator",
|
"name": "@0kmpo/openapi-clean-arch-generator",
|
||||||
"version": "1.0.1",
|
"version": "1.3.14",
|
||||||
"description": "Angular Clean Architecture generator from OpenAPI/Swagger",
|
"description": "Angular Clean Architecture generator from OpenAPI/Swagger",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -39,9 +39,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"publishConfig": {
|
"files": [
|
||||||
"registry": "https://git.blassanto.me/api/packages/blas/npm/"
|
"dist/main.js",
|
||||||
},
|
"dist/package.json",
|
||||||
|
"dist/src/",
|
||||||
|
"dist/templates/",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^4.1.2",
|
"chalk": "^4.1.2",
|
||||||
"commander": "^11.1.0",
|
"commander": "^11.1.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import mustache from 'mustache';
|
import mustache from 'mustache';
|
||||||
import { logStep, logSuccess, logInfo } from '../utils/logger';
|
import { logStep, logSuccess, logDetail } from '../utils/logger';
|
||||||
import { mapSwaggerTypeToTs } from '../utils/type-mapper';
|
import { mapSwaggerTypeToTs } from '../utils/type-mapper';
|
||||||
import { toCamelCase } from '../utils/name-formatter';
|
import { toCamelCase } from '../utils/name-formatter';
|
||||||
import { resolveMockValue } from '../utils/mock-value-resolver';
|
import { resolveMockValue } from '../utils/mock-value-resolver';
|
||||||
@@ -74,7 +74,8 @@ export function generateCleanArchitecture(
|
|||||||
mappers: 0,
|
mappers: 0,
|
||||||
useCases: 0,
|
useCases: 0,
|
||||||
providers: 0,
|
providers: 0,
|
||||||
mocks: 0
|
mocks: 0,
|
||||||
|
specs: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
const schemas =
|
const schemas =
|
||||||
@@ -156,7 +157,7 @@ export function generateCleanArchitecture(
|
|||||||
const destPath = path.join(outputDir, 'entities/models', `${toCamelCase(baseName)}.model.ts`);
|
const destPath = path.join(outputDir, 'entities/models', `${toCamelCase(baseName)}.model.ts`);
|
||||||
fs.writeFileSync(destPath, output);
|
fs.writeFileSync(destPath, output);
|
||||||
generatedCount.models++;
|
generatedCount.models++;
|
||||||
logInfo(` ${toCamelCase(baseName)}.model.ts → ${path.relative(process.cwd(), destPath)}`);
|
logDetail('generate', `model-entity → ${path.relative(process.cwd(), destPath)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mapper
|
// Mapper
|
||||||
@@ -210,6 +211,26 @@ export function generateCleanArchitecture(
|
|||||||
generatedCount,
|
generatedCount,
|
||||||
'mocks'
|
'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
|
// 2. Generate Use Cases and Repositories from Paths/Tags
|
||||||
@@ -227,7 +248,8 @@ export function generateCleanArchitecture(
|
|||||||
paramName: p.name,
|
paramName: p.name,
|
||||||
dataType: mapSwaggerTypeToTs(p.schema?.type || ''),
|
dataType: mapSwaggerTypeToTs(p.schema?.type || ''),
|
||||||
description: p.description || '',
|
description: p.description || '',
|
||||||
required: p.required
|
required: p.required,
|
||||||
|
testValue: resolveTestParamValue(mapSwaggerTypeToTs(p.schema?.type || ''))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (op.requestBody) {
|
if (op.requestBody) {
|
||||||
@@ -241,14 +263,19 @@ export function generateCleanArchitecture(
|
|||||||
paramName: 'body',
|
paramName: 'body',
|
||||||
dataType: bodyType,
|
dataType: bodyType,
|
||||||
description: op.requestBody.description || '',
|
description: op.requestBody.description || '',
|
||||||
required: true
|
required: true,
|
||||||
|
testValue: resolveTestParamValue(bodyType)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let returnType = 'void';
|
let returnType = 'void';
|
||||||
let returnBaseType = 'void';
|
let returnBaseType = 'void';
|
||||||
let isListContainer = false;
|
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) {
|
||||||
if (responseSchema.$ref) {
|
if (responseSchema.$ref) {
|
||||||
returnType = responseSchema.$ref.split('/').pop() || 'unknown';
|
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({
|
tagsMap[tag].push({
|
||||||
nickname: op.operationId || `${method}${pathKey.replace(/\//g, '_')}`,
|
nickname: op.operationId || `${method}${pathKey.replace(/\//g, '_')}`,
|
||||||
summary: op.summary || '',
|
summary: op.summary || '',
|
||||||
notes: op.description || '',
|
notes: op.description || '',
|
||||||
httpMethod: method.toLowerCase(),
|
httpMethod: method.toLowerCase(),
|
||||||
|
uppercaseHttpMethod: method.toUpperCase(),
|
||||||
path: pathKey,
|
path: pathKey,
|
||||||
allParams: allParams.map((p, i: number) => ({
|
allParams: allParams.map((p, i: number) => ({
|
||||||
...p,
|
...p,
|
||||||
'-last': i === allParams.length - 1
|
'-last': i === allParams.length - 1
|
||||||
})),
|
})),
|
||||||
hasQueryParams: (op.parameters || []).some((p) => p.in === 'query'),
|
hasQueryParams,
|
||||||
queryParams: (op.parameters || [])
|
queryParams: (op.parameters || [])
|
||||||
.filter((p) => p.in === 'query')
|
.filter((p) => p.in === 'query')
|
||||||
.map((p, i: number, arr: unknown[]) => ({
|
.map((p, i: number, arr: unknown[]) => ({
|
||||||
paramName: p.name,
|
paramName: p.name,
|
||||||
'-last': i === arr.length - 1
|
'-last': i === arr.length - 1
|
||||||
})),
|
})),
|
||||||
hasBodyParam: !!op.requestBody,
|
hasBodyParam,
|
||||||
bodyParam: 'body',
|
bodyParam: 'body',
|
||||||
|
hasOptions: hasQueryParams || hasBodyParam,
|
||||||
|
hasBothParamsAndBody: hasQueryParams && hasBodyParam,
|
||||||
returnType: returnType !== 'void' ? returnType : false,
|
returnType: returnType !== 'void' ? returnType : false,
|
||||||
returnBaseType: returnBaseType !== 'void' ? returnBaseType : false,
|
returnBaseType: returnBaseType !== 'void' ? returnBaseType : false,
|
||||||
returnTypeVarName: returnType !== 'void' ? toCamelCase(returnType) : false,
|
returnTypeVarName: returnType !== 'void' ? toCamelCase(returnType) : false,
|
||||||
@@ -438,10 +471,30 @@ export function generateCleanArchitecture(
|
|||||||
generatedCount,
|
generatedCount,
|
||||||
'mocks'
|
'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(
|
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;
|
return generatedCount;
|
||||||
}
|
}
|
||||||
@@ -461,5 +514,24 @@ function renderTemplate(
|
|||||||
const output = mustache.render(template, viewData);
|
const output = mustache.render(template, viewData);
|
||||||
fs.writeFileSync(destPath, output);
|
fs.writeFileSync(destPath, output);
|
||||||
counter[key]++;
|
counter[key]++;
|
||||||
|
logDetail(
|
||||||
|
'generate',
|
||||||
|
`${templateName.replace('.mustache', '')} → ${path.relative(process.cwd(), destPath)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { logStep, logSuccess, logError, logInfo } from '../utils/logger';
|
import { logStep, logSuccess, logError, logDetail } from '../utils/logger';
|
||||||
|
|
||||||
/** Invokes `openapi-generator-cli` to generate DTOs into a temporary directory. */
|
/** Invokes `openapi-generator-cli` to generate DTOs into a temporary directory. */
|
||||||
export function generateCode(swaggerFile: string, templatesDir: string): string {
|
export function generateCode(swaggerFile: string, templatesDir: string): string {
|
||||||
@@ -22,7 +22,7 @@ export function generateCode(swaggerFile: string, templatesDir: string): string
|
|||||||
-o "${tempDir}" \
|
-o "${tempDir}" \
|
||||||
--additional-properties=ngVersion=17.0.0,modelFileSuffix=.dto,modelNameSuffix=Dto`;
|
--additional-properties=ngVersion=17.0.0,modelFileSuffix=.dto,modelNameSuffix=Dto`;
|
||||||
|
|
||||||
execSync(command, { stdio: 'inherit' });
|
execSync(command, { stdio: 'pipe' });
|
||||||
logSuccess('Code generated successfully');
|
logSuccess('Code generated successfully');
|
||||||
|
|
||||||
return tempDir;
|
return tempDir;
|
||||||
@@ -51,10 +51,9 @@ export function organizeFiles(tempDir: string, outputDir: string): void {
|
|||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
const sourcePath = path.join(sourceDir, file);
|
const sourcePath = path.join(sourceDir, file);
|
||||||
const destPath = path.join(destDir, file);
|
const destPath = path.join(destDir, file);
|
||||||
|
|
||||||
fs.copySync(sourcePath, destPath);
|
fs.copySync(sourcePath, destPath);
|
||||||
filesMoved++;
|
filesMoved++;
|
||||||
logInfo(` ${file} → ${path.relative(process.cwd(), destPath)}`);
|
logDetail('dto', `${file} → ${path.relative(process.cwd(), destPath)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ export function addDtoImports(outputDir: string): void {
|
|||||||
if (content !== originalContent) {
|
if (content !== originalContent) {
|
||||||
fs.writeFileSync(filePath, content);
|
fs.writeFileSync(filePath, content);
|
||||||
filesProcessed++;
|
filesProcessed++;
|
||||||
logInfo(` Procesado ${file}`);
|
logDetail('dto', `Post-processed ${file} (added ${imports.length} import(s))`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { spawnSync } from 'child_process';
|
import { spawnSync } from 'child_process';
|
||||||
import { logStep, logSuccess, logWarning, logInfo } from '../utils/logger';
|
import { logStep, logSuccess, logWarning, logDetail } from '../utils/logger';
|
||||||
|
import type { LintResult } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walks up the directory tree from `startDir` to find the nearest
|
* Walks up the directory tree from `startDir` to find the nearest
|
||||||
@@ -36,17 +37,19 @@ function collectTsFiles(dir: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs a command synchronously and returns whether it succeeded.
|
* Runs a command synchronously. Only prints to console on fatal failure (exit >= 2).
|
||||||
* Prints stdout/stderr to the console only on failure.
|
* Exit code 1 from ESLint means "warnings remain after --fix" — not a fatal error.
|
||||||
|
* Returns captured output for logging to file.
|
||||||
*/
|
*/
|
||||||
function run(cmd: string, args: string[], cwd: string): boolean {
|
function run(cmd: string, args: string[], cwd: string): { success: boolean; output: string } {
|
||||||
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8', shell: true });
|
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8', shell: true });
|
||||||
if (result.status !== 0) {
|
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
||||||
|
const fatalError = result.status === null || result.status >= 2;
|
||||||
|
if (fatalError) {
|
||||||
if (result.stderr) process.stderr.write(result.stderr);
|
if (result.stderr) process.stderr.write(result.stderr);
|
||||||
if (result.stdout) process.stdout.write(result.stdout);
|
if (result.stdout) process.stdout.write(result.stdout);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
return { success: !fatalError, output };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,28 +59,39 @@ function run(cmd: string, args: string[], cwd: string): boolean {
|
|||||||
*
|
*
|
||||||
* - Prettier: always attempted; logs a warning if not found.
|
* - Prettier: always attempted; logs a warning if not found.
|
||||||
* - ESLint: optional; silently skipped if no config is found in the project root.
|
* - ESLint: optional; silently skipped if no config is found in the project root.
|
||||||
|
*
|
||||||
|
* Returns a `LintResult` with the outcome of each tool for inclusion in the report.
|
||||||
*/
|
*/
|
||||||
export function lintGeneratedFiles(outputDir: string): void {
|
export function lintGeneratedFiles(outputDir: string): LintResult {
|
||||||
logStep('Linting generated files...');
|
logStep('Linting generated files...');
|
||||||
|
|
||||||
|
const result: LintResult = {
|
||||||
|
prettier: { ran: false, filesFormatted: 0 },
|
||||||
|
eslint: { ran: false, filesFixed: 0 }
|
||||||
|
};
|
||||||
|
|
||||||
const projectRoot = findProjectRoot(outputDir);
|
const projectRoot = findProjectRoot(outputDir);
|
||||||
if (!projectRoot) {
|
if (!projectRoot) {
|
||||||
logWarning('Could not locate a project root (package.json). Skipping lint.');
|
logWarning('Could not locate a project root (package.json). Skipping lint.');
|
||||||
return;
|
return result;
|
||||||
}
|
}
|
||||||
logInfo(` Project root: ${projectRoot}`);
|
|
||||||
|
|
||||||
const files = collectTsFiles(outputDir);
|
const files = collectTsFiles(outputDir);
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
logWarning('No TypeScript files found in output directory. Skipping lint.');
|
logWarning('No TypeScript files found in output directory. Skipping lint.');
|
||||||
return;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logDetail('lint', `Project root: ${projectRoot}`);
|
||||||
|
logDetail('lint', `Files to process: ${files.length}`);
|
||||||
|
|
||||||
const relativePaths = files.map((f) => path.relative(projectRoot, f));
|
const relativePaths = files.map((f) => path.relative(projectRoot, f));
|
||||||
|
|
||||||
// --- Prettier ---
|
// --- Prettier ---
|
||||||
const prettierOk = run('npx', ['prettier', '--write', ...relativePaths], projectRoot);
|
const prettier = run('npx', ['prettier', '--write', ...relativePaths], projectRoot);
|
||||||
if (prettierOk) {
|
if (prettier.output) logDetail('prettier', prettier.output);
|
||||||
|
if (prettier.success) {
|
||||||
|
result.prettier = { ran: true, filesFormatted: files.length };
|
||||||
logSuccess(`Prettier formatted ${files.length} files`);
|
logSuccess(`Prettier formatted ${files.length} files`);
|
||||||
} else {
|
} else {
|
||||||
logWarning('Prettier not available or encountered errors. Skipping formatting.');
|
logWarning('Prettier not available or encountered errors. Skipping formatting.');
|
||||||
@@ -94,13 +108,17 @@ export function lintGeneratedFiles(outputDir: string): void {
|
|||||||
|
|
||||||
if (!hasEslintConfig) {
|
if (!hasEslintConfig) {
|
||||||
logWarning('No ESLint config found in project root. Skipping ESLint fix.');
|
logWarning('No ESLint config found in project root. Skipping ESLint fix.');
|
||||||
return;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eslintOk = run('npx', ['eslint', '--fix', ...relativePaths], projectRoot);
|
const eslint = run('npx', ['eslint', '--fix', ...relativePaths], projectRoot);
|
||||||
if (eslintOk) {
|
if (eslint.output) logDetail('eslint', eslint.output);
|
||||||
|
if (eslint.success) {
|
||||||
|
result.eslint = { ran: true, filesFixed: files.length };
|
||||||
logSuccess(`ESLint fixed ${files.length} files`);
|
logSuccess(`ESLint fixed ${files.length} files`);
|
||||||
} else {
|
} else {
|
||||||
logWarning('ESLint reported errors that could not be auto-fixed. Review the output above.');
|
logWarning('ESLint reported errors that could not be auto-fixed. Review the output above.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { logStep, logSuccess } from '../utils/logger';
|
import { logStep, logSuccess } from '../utils/logger';
|
||||||
import type { SwaggerAnalysis, GenerationReport } from '../types';
|
import type { SwaggerAnalysis, GenerationReport, LintResult } from '../types';
|
||||||
|
|
||||||
/** Counts files ending with `.mock.ts` in a directory (returns 0 if directory does not exist). */
|
/** Counts files ending with `.mock.ts` in a directory (returns 0 if directory does not exist). */
|
||||||
function countMockFiles(dir: string): number {
|
function countMockFiles(dir: string): number {
|
||||||
@@ -12,15 +12,42 @@ 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. */
|
/** Generates and persists the `generation-report.json` file with process statistics. */
|
||||||
export function generateReport(outputDir: string, analysis: SwaggerAnalysis): GenerationReport {
|
export function generateReport(
|
||||||
|
outputDir: string,
|
||||||
|
analysis: SwaggerAnalysis,
|
||||||
|
lintResult: LintResult
|
||||||
|
): GenerationReport {
|
||||||
logStep('Generating report...');
|
logStep('Generating report...');
|
||||||
|
|
||||||
|
const tags = Array.isArray(analysis.tags) ? analysis.tags : [];
|
||||||
|
const tagDetails = tags.map((tag: unknown) => {
|
||||||
|
const t = tag as { name: string; description?: string };
|
||||||
|
const endpointCount = Object.values(analysis.paths).filter((pathObj) =>
|
||||||
|
Object.values(pathObj as Record<string, unknown>).some((op) => {
|
||||||
|
const operation = op as { tags?: string[] };
|
||||||
|
return operation.tags?.includes(t.name);
|
||||||
|
})
|
||||||
|
).length;
|
||||||
|
return { name: t.name, description: t.description || '', endpoints: endpointCount };
|
||||||
|
});
|
||||||
|
|
||||||
const report: GenerationReport = {
|
const report: GenerationReport = {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
tags: analysis.tags.length,
|
tags: analysis.tags.length,
|
||||||
endpoints: Object.keys(analysis.paths).length,
|
endpoints: Object.keys(analysis.paths).length,
|
||||||
|
tagDetails,
|
||||||
outputDirectory: outputDir,
|
outputDirectory: outputDir,
|
||||||
|
linting: lintResult,
|
||||||
structure: {
|
structure: {
|
||||||
dtos: fs.readdirSync(path.join(outputDir, 'data/dtos')).length,
|
dtos: fs.readdirSync(path.join(outputDir, 'data/dtos')).length,
|
||||||
repositories: fs.readdirSync(path.join(outputDir, 'data/repositories')).length,
|
repositories: fs.readdirSync(path.join(outputDir, 'data/repositories')).length,
|
||||||
@@ -35,7 +62,12 @@ export function generateReport(outputDir: string, analysis: SwaggerAnalysis): Ge
|
|||||||
countMockFiles(path.join(outputDir, 'di/repositories')) +
|
countMockFiles(path.join(outputDir, 'di/repositories')) +
|
||||||
countMockFiles(path.join(outputDir, 'di/use-cases')) +
|
countMockFiles(path.join(outputDir, 'di/use-cases')) +
|
||||||
countMockFiles(path.join(outputDir, 'domain/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'))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
import { logStep, logInfo, logError } from '../utils/logger';
|
import { logStep, logSuccess, logError, logDetail } from '../utils/logger';
|
||||||
import type { SwaggerAnalysis } from '../types';
|
import type { SwaggerAnalysis } from '../types';
|
||||||
|
|
||||||
/** Parses an OpenAPI/Swagger file and extracts tags, paths and the full document. */
|
/** Parses an OpenAPI/Swagger file and extracts tags, paths and the full document. */
|
||||||
@@ -14,14 +14,15 @@ export function analyzeSwagger(swaggerFile: string): SwaggerAnalysis {
|
|||||||
const tags = Array.isArray(swagger.tags) ? swagger.tags : [];
|
const tags = Array.isArray(swagger.tags) ? swagger.tags : [];
|
||||||
const paths = (swagger.paths as Record<string, unknown>) || {};
|
const paths = (swagger.paths as Record<string, unknown>) || {};
|
||||||
|
|
||||||
logInfo(`Found ${tags.length} tags in the API`);
|
logDetail('analyze', `Input: ${swaggerFile}`);
|
||||||
logInfo(`Found ${Object.keys(paths).length} endpoints`);
|
logDetail('analyze', `Found ${tags.length} tags, ${Object.keys(paths).length} endpoints`);
|
||||||
|
|
||||||
tags.forEach((tag: unknown) => {
|
tags.forEach((tag: unknown) => {
|
||||||
const t = tag as { name: string; description?: string };
|
const t = tag as { name: string; description?: string };
|
||||||
logInfo(` - ${t.name}: ${t.description || 'No description'}`);
|
logDetail('analyze', ` - ${t.name}: ${t.description || 'No description'}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logSuccess(`${tags.length} tags, ${Object.keys(paths).length} endpoints found`);
|
||||||
|
|
||||||
return { tags, paths, swagger };
|
return { tags, paths, swagger };
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
|
|||||||
@@ -10,4 +10,27 @@ export interface CliOptions {
|
|||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
selectEndpoints?: boolean;
|
selectEndpoints?: boolean;
|
||||||
skipLint?: boolean;
|
skipLint?: boolean;
|
||||||
|
config?: string;
|
||||||
|
initConfig?: string | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-tag configuration inside the generation config file.
|
||||||
|
*/
|
||||||
|
export interface TagConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
endpoints: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON configuration file schema.
|
||||||
|
* Allows full non-interactive control of the generation process.
|
||||||
|
*/
|
||||||
|
export interface GenerationConfig {
|
||||||
|
input: string;
|
||||||
|
output: string;
|
||||||
|
templates?: string;
|
||||||
|
skipInstall?: boolean;
|
||||||
|
skipLint?: boolean;
|
||||||
|
tags: Record<string, TagConfig>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,24 @@ export interface GeneratedCount {
|
|||||||
useCases: number;
|
useCases: number;
|
||||||
providers: number;
|
providers: number;
|
||||||
mocks: number;
|
mocks: number;
|
||||||
|
specs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result returned by the lint/format step.
|
||||||
|
*/
|
||||||
|
export interface LintResult {
|
||||||
|
prettier: { ran: boolean; filesFormatted: number };
|
||||||
|
eslint: { ran: boolean; filesFixed: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-tag summary included in the generation report.
|
||||||
|
*/
|
||||||
|
export interface TagDetail {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
endpoints: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +35,9 @@ export interface GenerationReport {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
tags: number;
|
tags: number;
|
||||||
endpoints: number;
|
endpoints: number;
|
||||||
|
tagDetails: TagDetail[];
|
||||||
outputDirectory: string;
|
outputDirectory: string;
|
||||||
|
linting: LintResult;
|
||||||
structure: {
|
structure: {
|
||||||
dtos: number;
|
dtos: number;
|
||||||
repositories: number;
|
repositories: number;
|
||||||
@@ -25,5 +45,6 @@ export interface GenerationReport {
|
|||||||
useCases: number;
|
useCases: number;
|
||||||
providers: number;
|
providers: number;
|
||||||
mocks: number;
|
mocks: number;
|
||||||
|
specs: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface TagOperationParam {
|
|||||||
description: string;
|
description: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
'-last': boolean;
|
'-last': boolean;
|
||||||
|
testValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,12 +108,15 @@ export interface TagOperation {
|
|||||||
summary: string;
|
summary: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
httpMethod: string;
|
httpMethod: string;
|
||||||
|
uppercaseHttpMethod: string;
|
||||||
path: string;
|
path: string;
|
||||||
allParams: TagOperationParam[];
|
allParams: TagOperationParam[];
|
||||||
hasQueryParams: boolean;
|
hasQueryParams: boolean;
|
||||||
queryParams: unknown[];
|
queryParams: unknown[];
|
||||||
hasBodyParam: boolean;
|
hasBodyParam: boolean;
|
||||||
bodyParam: string;
|
bodyParam: string;
|
||||||
|
hasOptions: boolean;
|
||||||
|
hasBothParamsAndBody: boolean;
|
||||||
returnType: string | boolean;
|
returnType: string | boolean;
|
||||||
returnBaseType: string | boolean;
|
returnBaseType: string | boolean;
|
||||||
returnTypeVarName: string | boolean;
|
returnTypeVarName: string | boolean;
|
||||||
|
|||||||
118
src/utils/config.ts
Normal file
118
src/utils/config.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import fs from 'fs-extra';
|
||||||
|
import { logInfo, logError } from './logger';
|
||||||
|
import type { GenerationConfig, TagConfig } from '../types';
|
||||||
|
import type { SwaggerAnalysis } from '../types';
|
||||||
|
import type { TagSummary } from '../types';
|
||||||
|
import type { ApiKeyInfo } from './environment-finder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and validates a GenerationConfig from a JSON file.
|
||||||
|
*/
|
||||||
|
export function loadConfig(filePath: string): GenerationConfig {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
logError(`Configuration file not found: ${filePath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf8');
|
||||||
|
let config: GenerationConfig;
|
||||||
|
|
||||||
|
try {
|
||||||
|
config = JSON.parse(raw) as GenerationConfig;
|
||||||
|
} catch {
|
||||||
|
logError(`Invalid JSON in configuration file: ${filePath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.tags || typeof config.tags !== 'object') {
|
||||||
|
logError('Configuration file must contain a "tags" object');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [tag, tagConfig] of Object.entries(config.tags)) {
|
||||||
|
if (!tagConfig.baseUrl || typeof tagConfig.baseUrl !== 'string') {
|
||||||
|
logError(`Tag "${tag}" must have a "baseUrl" string`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(tagConfig.endpoints) || tagConfig.endpoints.length === 0) {
|
||||||
|
logError(`Tag "${tag}" must have a non-empty "endpoints" array`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a default GenerationConfig from a swagger analysis, including all tags
|
||||||
|
* and all endpoints. Useful for --init-config to scaffold a config template.
|
||||||
|
*/
|
||||||
|
export function generateDefaultConfig(
|
||||||
|
analysis: SwaggerAnalysis,
|
||||||
|
tagSummaries: TagSummary[],
|
||||||
|
cliOptions: {
|
||||||
|
input: string;
|
||||||
|
output: string;
|
||||||
|
templates?: string;
|
||||||
|
skipLint?: boolean;
|
||||||
|
skipInstall?: boolean;
|
||||||
|
},
|
||||||
|
apiKeys: ApiKeyInfo[]
|
||||||
|
): GenerationConfig {
|
||||||
|
const tags: Record<string, TagConfig> = {};
|
||||||
|
|
||||||
|
for (const summary of tagSummaries) {
|
||||||
|
const matchingKey = apiKeys.find((k) =>
|
||||||
|
k.key.toLowerCase().includes(summary.tag.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
tags[summary.tag] = {
|
||||||
|
baseUrl: matchingKey?.key || 'apiUrl',
|
||||||
|
endpoints: summary.operations.map((op) => op.nickname)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: GenerationConfig = {
|
||||||
|
input: cliOptions.input,
|
||||||
|
output: cliOptions.output,
|
||||||
|
skipLint: cliOptions.skipLint ?? false,
|
||||||
|
skipInstall: cliOptions.skipInstall ?? false,
|
||||||
|
tags
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cliOptions.templates) {
|
||||||
|
config.templates = cliOptions.templates;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a GenerationConfig to a JSON file.
|
||||||
|
*/
|
||||||
|
export function writeConfig(config: GenerationConfig, filePath: string): void {
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
||||||
|
logInfo(`Configuration file written to: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the selectionFilter (tag → endpoint nicknames) from a GenerationConfig.
|
||||||
|
*/
|
||||||
|
export function deriveSelectionFilter(config: GenerationConfig): Record<string, string[]> {
|
||||||
|
const filter: Record<string, string[]> = {};
|
||||||
|
for (const [tag, tagConfig] of Object.entries(config.tags)) {
|
||||||
|
filter[tag] = [...tagConfig.endpoints];
|
||||||
|
}
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the tagApiKeyMap (tag → baseUrl key) from a GenerationConfig.
|
||||||
|
*/
|
||||||
|
export function deriveTagApiKeyMap(config: GenerationConfig): Record<string, string> {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const [tag, tagConfig] of Object.entries(config.tags)) {
|
||||||
|
map[tag] = tagConfig.baseUrl;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import fs from 'fs-extra';
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
reset: '\x1b[0m',
|
reset: '\x1b[0m',
|
||||||
bright: '\x1b[1m',
|
bright: '\x1b[1m',
|
||||||
@@ -10,6 +12,21 @@ const colors = {
|
|||||||
|
|
||||||
type Color = keyof typeof colors;
|
type Color = keyof typeof colors;
|
||||||
|
|
||||||
|
let _logFilePath: string | null = null;
|
||||||
|
|
||||||
|
/** Initialises the generation log file, overwriting any previous run. */
|
||||||
|
export function initGenerationLog(filePath: string): void {
|
||||||
|
_logFilePath = filePath;
|
||||||
|
fs.writeFileSync(filePath, `Generation log — ${new Date().toISOString()}\n${'='.repeat(60)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a detailed entry to the generation log file (not to console). */
|
||||||
|
export function logDetail(category: string, message: string): void {
|
||||||
|
if (!_logFilePath) return;
|
||||||
|
const line = `[${new Date().toISOString()}] [${category.toUpperCase().padEnd(8)}] ${message}\n`;
|
||||||
|
fs.appendFileSync(_logFilePath, line);
|
||||||
|
}
|
||||||
|
|
||||||
/** Prints a console message with the given ANSI colour. */
|
/** Prints a console message with the given ANSI colour. */
|
||||||
export function log(message: string, color: Color = 'reset'): void {
|
export function log(message: string, color: Color = 'reset'): void {
|
||||||
console.log(`${colors[color]}${message}${colors.reset}`);
|
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||||
|
|||||||
@@ -32,26 +32,20 @@ export class {{classname}}RepositoryImpl extends MRepository implements {{classn
|
|||||||
{{#operation}}
|
{{#operation}}
|
||||||
{{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}{{^-last}}, {{/-last}}{{/allParams}}): Observable<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}> {
|
{{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}{{^-last}}, {{/-last}}{{/allParams}}): Observable<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}void{{/returnType}}> {
|
||||||
{{#isListContainer}}
|
{{#isListContainer}}
|
||||||
return this.{{httpMethod}}<{{{returnBaseType}}}Dto>('{{path}}'{{#hasQueryParams}}, {
|
return this.{{httpMethod}}<{{{returnBaseType}}}Dto>('{{path}}'{{#hasOptions}}, { {{#hasQueryParams}}params: { {{#queryParams}}{{paramName}}{{^-last}}, {{/-last}}{{/queryParams}} }{{/hasQueryParams}}{{#hasBothParamsAndBody}}, {{/hasBothParamsAndBody}}{{#hasBodyParam}}body{{/hasBodyParam}} }{{/hasOptions}})
|
||||||
params: { {{#queryParams}}{{paramName}}{{^-last}}, {{/-last}}{{/queryParams}} }
|
|
||||||
}{{/hasQueryParams}}{{#hasBodyParam}}, {{bodyParam}}{{/hasBodyParam}})
|
|
||||||
.pipe(
|
.pipe(
|
||||||
map((response) => response.{{#vendorExtensions}}{{x-response-property}}{{/vendorExtensions}}{{^vendorExtensions}}items{{/vendorExtensions}}.map({{{returnBaseTypeVarName}}}Mapper))
|
map((response) => response.{{#vendorExtensions}}{{x-response-property}}{{/vendorExtensions}}{{^vendorExtensions}}items{{/vendorExtensions}}.map({{{returnBaseTypeVarName}}}Mapper))
|
||||||
);
|
);
|
||||||
{{/isListContainer}}
|
{{/isListContainer}}
|
||||||
{{^isListContainer}}
|
{{^isListContainer}}
|
||||||
{{#returnType}}
|
{{#returnType}}
|
||||||
return this.{{httpMethod}}<{{{returnType}}}Dto>('{{path}}'{{#hasQueryParams}}, {
|
return this.{{httpMethod}}<{{{returnType}}}Dto>('{{path}}'{{#hasOptions}}, { {{#hasQueryParams}}params: { {{#queryParams}}{{paramName}}{{^-last}}, {{/-last}}{{/queryParams}} }{{/hasQueryParams}}{{#hasBothParamsAndBody}}, {{/hasBothParamsAndBody}}{{#hasBodyParam}}body{{/hasBodyParam}} }{{/hasOptions}})
|
||||||
params: { {{#queryParams}}{{paramName}}{{^-last}}, {{/-last}}{{/queryParams}} }
|
|
||||||
}{{/hasQueryParams}}{{#hasBodyParam}}, {{bodyParam}}{{/hasBodyParam}})
|
|
||||||
.pipe(
|
.pipe(
|
||||||
map({{{returnTypeVarName}}}Mapper)
|
map({{{returnTypeVarName}}}Mapper)
|
||||||
);
|
);
|
||||||
{{/returnType}}
|
{{/returnType}}
|
||||||
{{^returnType}}
|
{{^returnType}}
|
||||||
return this.{{httpMethod}}<void>('{{path}}'{{#hasQueryParams}}, {
|
return this.{{httpMethod}}<void>('{{path}}'{{#hasOptions}}, { {{#hasQueryParams}}params: { {{#queryParams}}{{paramName}}{{^-last}}, {{/-last}}{{/queryParams}} }{{/hasQueryParams}}{{#hasBothParamsAndBody}}, {{/hasBothParamsAndBody}}{{#hasBodyParam}}body{{/hasBodyParam}} }{{/hasOptions}});
|
||||||
params: { {{#queryParams}}{{paramName}}{{^-last}}, {{/-last}}{{/queryParams}} }
|
|
||||||
}{{/hasQueryParams}}{{#hasBodyParam}}, {{bodyParam}}{{/hasBodyParam}});
|
|
||||||
{{/returnType}}
|
{{/returnType}}
|
||||||
{{/isListContainer}}
|
{{/isListContainer}}
|
||||||
}
|
}
|
||||||
|
|||||||
97
templates/api.repository.impl.spec.mustache
Normal file
97
templates/api.repository.impl.spec.mustache
Normal 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}}
|
||||||
94
templates/api.use-cases.impl.spec.mustache
Normal file
94
templates/api.use-cases.impl.spec.mustache
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
{{#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: () => {
|
||||||
|
expect(mockRepository.{{nickname}}).toHaveBeenCalledOnceWith({{#allParams}}{{{testValue}}}{{^-last}}, {{/-last}}{{/allParams}});
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{{/returnBaseType}}
|
||||||
|
{{/isListContainer}}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
{{/operation}}
|
||||||
|
});
|
||||||
|
|
||||||
|
{{/operations}}
|
||||||
|
{{/apis}}
|
||||||
|
{{/apiInfo}}
|
||||||
39
templates/mapper.spec.mustache
Normal file
39
templates/mapper.spec.mustache
Normal 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}}
|
||||||
34
templates/model-entity.spec.mustache
Normal file
34
templates/model-entity.spec.mustache
Normal 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}}
|
||||||
Reference in New Issue
Block a user