@ygorazambuja/sauron 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ # Plugin Development Guide
2
+
3
+ Este guia descreve como criar um novo plugin HTTP no Sauron.
4
+
5
+ ## Visao geral
6
+
7
+ O plugin system atual cobre geracao HTTP. O fluxo principal esta em:
8
+
9
+ - `src/plugins/types.ts`
10
+ - `src/plugins/registry.ts`
11
+ - `src/plugins/runner.ts`
12
+ - `src/plugins/builtin/`
13
+
14
+ O `main` gera models no core e delega clientes HTTP ao `runHttpPlugins`.
15
+
16
+ ## Contrato do plugin
17
+
18
+ Todo plugin deve implementar `SauronPlugin` (em `src/plugins/types.ts`):
19
+
20
+ - `id: string`
21
+ - `aliases?: string[]`
22
+ - `kind: "http-client"`
23
+ - `canRun(context): { ok: true } | { ok: false; reason: string; fallbackPluginId?: string }`
24
+ - `resolveOutputs(context): { servicePath: string; reportPath: string }`
25
+ - `generate(context): Promise<{ files: Array<{ path: string; content: string }>; methodCount: number }>`
26
+
27
+ `PluginContext` oferece os dados necessarios:
28
+
29
+ - schema OpenAPI validado (`schema`)
30
+ - tipos derivados por operacao (`operationTypes`, `typeNameMap`)
31
+ - opcoes resolvidas (`options`)
32
+ - caminhos de saida (`baseOutputPath`, `modelsPath`)
33
+ - cabecalho padrao (`fileHeader`)
34
+ - status de projeto Angular (`isAngularProject`)
35
+ - escritor formatado (`writeFormattedFile`)
36
+
37
+ ## Passo a passo para criar um plugin
38
+
39
+ 1. Criar arquivo em `src/plugins/builtin/<nome>.ts`.
40
+ 2. Implementar `create<Nome>Plugin(): SauronPlugin`.
41
+ 3. Definir `canRun`.
42
+ 4. Definir `resolveOutputs` com paths estaveis do plugin.
43
+ 5. Definir `generate` para retornar todos os arquivos do plugin.
44
+ 6. Incluir relatorio de definicoes ausentes no `generate`.
45
+ 7. Registrar plugin em `src/plugins/registry.ts`.
46
+ 8. Atualizar ajuda de CLI em `src/cli/args.ts` (opcoes de `--plugin`).
47
+ 9. Adicionar/atualizar testes de registry e main.
48
+
49
+ ## Template minimo
50
+
51
+ ```ts
52
+ import { join } from "node:path";
53
+ import {
54
+ createMissingSwaggerDefinitionsReport,
55
+ generateMissingSwaggerDefinitionsFile,
56
+ } from "../../generators/missing-definitions";
57
+ import type {
58
+ PluginCanRunResult,
59
+ PluginContext,
60
+ PluginGenerateResult,
61
+ PluginOutputPaths,
62
+ SauronPlugin,
63
+ } from "../types";
64
+
65
+ export function createExamplePlugin(): SauronPlugin {
66
+ return {
67
+ id: "example",
68
+ aliases: ["ex"],
69
+ kind: "http-client",
70
+ canRun,
71
+ resolveOutputs,
72
+ generate,
73
+ };
74
+ }
75
+
76
+ function canRun(_context: PluginContext): PluginCanRunResult {
77
+ return { ok: true };
78
+ }
79
+
80
+ function resolveOutputs(context: PluginContext): PluginOutputPaths {
81
+ const serviceDirectory = join(context.baseOutputPath, "http-client");
82
+ return {
83
+ servicePath: join(serviceDirectory, "sauron-api.example-client.ts"),
84
+ reportPath: join(serviceDirectory, "missing-swagger-definitions.example.json"),
85
+ };
86
+ }
87
+
88
+ async function generate(context: PluginContext): Promise<PluginGenerateResult> {
89
+ const serviceSource = `export class ExampleClient {}`;
90
+ const outputPaths = resolveOutputs(context);
91
+
92
+ const missingDefinitionsReport = createMissingSwaggerDefinitionsReport(
93
+ context.schema,
94
+ context.operationTypes,
95
+ );
96
+ const reportFileContent = generateMissingSwaggerDefinitionsFile(
97
+ missingDefinitionsReport,
98
+ );
99
+
100
+ return {
101
+ files: [
102
+ {
103
+ path: outputPaths.servicePath,
104
+ content: `${context.fileHeader}\n${serviceSource}`,
105
+ },
106
+ {
107
+ path: outputPaths.reportPath,
108
+ content: reportFileContent,
109
+ },
110
+ ],
111
+ methodCount: 0,
112
+ };
113
+ }
114
+ ```
115
+
116
+ ## Registro do plugin
117
+
118
+ Em `src/plugins/registry.ts`:
119
+
120
+ 1. Importar `createExamplePlugin`.
121
+ 2. Adicionar o id em `BUILTIN_PLUGIN_IDS`.
122
+ 3. Incluir instancia no `createDefaultPluginRegistry`.
123
+
124
+ ## Fallbacks
125
+
126
+ Use fallback quando o plugin nao puder rodar no contexto atual.
127
+
128
+ Exemplo (plugin angular):
129
+
130
+ - `canRun` retorna `{ ok: false, reason: "...", fallbackPluginId: "fetch" }`
131
+ - runner resolve automaticamente o fallback
132
+
133
+ ## Testes recomendados
134
+
135
+ - `src/plugins/registry.spec.ts`
136
+ - `src/plugins/runner.spec.ts`
137
+ - `src/cli/main.spec.ts`
138
+
139
+ Cenarios minimos:
140
+
141
+ - resolve por `id`
142
+ - resolve por `alias`
143
+ - erro para plugin desconhecido
144
+ - geracao de arquivo do novo plugin
145
+ - fallback (se aplicavel)
146
+ - compatibilidade com aliases de CLI (`--http`, `--angular`)
147
+
148
+ ## Boas praticas
149
+
150
+ - Reaproveitar geradores existentes sempre que possivel.
151
+ - Manter o plugin focado em uma responsabilidade (geracao HTTP).
152
+ - Gerar caminho de output previsivel e estavel.
153
+ - Incluir o relatorio de definicoes ausentes em todos os plugins HTTP.
154
+ - Evitar quebrar comportamento legado sem migracao clara.
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@ygorazambuja/sauron",
3
+ "version": "1.0.0",
4
+ "description": "OpenAPI to TypeScript/Angular Converter - Generate TypeScript interfaces and Angular HTTP services from Swagger/OpenAPI specs",
5
+ "main": "src/index.ts",
6
+ "files": [
7
+ "bin.ts",
8
+ "src/**/*.ts",
9
+ "!src/**/*.spec.ts",
10
+ "!src/**/*.test.ts",
11
+ "!src/app/sauron/**",
12
+ "README.md",
13
+ "docs/plugins.md"
14
+ ],
15
+ "bin": {
16
+ "sauron": "./bin.ts"
17
+ },
18
+ "engines": {
19
+ "bun": ">=1.3.0"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "start": "bun run src/index.ts",
26
+ "dev": "bun run src/index.ts",
27
+ "test": "bun test",
28
+ "test:watch": "bun test --watch",
29
+ "test:coverage": "bun test --coverage",
30
+ "build": "bun build --compile ./src/index.ts --outfile sauron",
31
+ "cli": "bun run ./bin.ts",
32
+ "format": "biome check --fix"
33
+ },
34
+ "keywords": [
35
+ "openapi",
36
+ "swagger",
37
+ "typescript",
38
+ "angular",
39
+ "http-client",
40
+ "api-client",
41
+ "code-generator"
42
+ ],
43
+ "author": "Ygor Correa Azambuja",
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/ygorazambuja/sauron"
48
+ },
49
+ "devDependencies": {
50
+ "@biomejs/biome": "^2.3.15",
51
+ "@types/bun": "^1.3.9",
52
+ "bun-types": "^1.3.9"
53
+ },
54
+ "dependencies": {
55
+ "prettier": "^3.8.1",
56
+ "query-string": "^9.3.1",
57
+ "zod": "^4.3.6"
58
+ }
59
+ }
@@ -0,0 +1,196 @@
1
+ import { parseArgs as parseCliArgs } from "node:util";
2
+ import type { CliOptions } from "./types";
3
+
4
+ /**
5
+ * Parse command.
6
+ * @returns Parse command output as `"generate" | "init"`.
7
+ * @example
8
+ * ```ts
9
+ * const result = parseCommand();
10
+ * // result: "generate" | "init"
11
+ * ```
12
+ */
13
+ export function parseCommand(): "generate" | "init" {
14
+ const { positionals } = parseCliArgs({
15
+ args: Bun.argv,
16
+ options: {},
17
+ strict: false,
18
+ allowPositionals: true,
19
+ });
20
+
21
+ const command = positionals.slice(2)[0];
22
+ return command === "init" ? "init" : "generate";
23
+ }
24
+
25
+ /**
26
+ * Parse args.
27
+ * @returns Parse args output as `CliOptions`.
28
+ * @example
29
+ * ```ts
30
+ * const result = parseArgs();
31
+ * // result: CliOptions
32
+ * ```
33
+ */
34
+ export function parseArgs(): CliOptions {
35
+ const { values, positionals } = parseCliArgs({
36
+ args: Bun.argv,
37
+ options: {
38
+ input: {
39
+ type: "string",
40
+ short: "i",
41
+ },
42
+ url: {
43
+ type: "string",
44
+ short: "u",
45
+ },
46
+ angular: {
47
+ type: "boolean",
48
+ short: "a",
49
+ },
50
+ http: {
51
+ type: "boolean",
52
+ short: "t",
53
+ },
54
+ plugin: {
55
+ type: "string",
56
+ short: "p",
57
+ multiple: true,
58
+ },
59
+ output: {
60
+ type: "string",
61
+ short: "o",
62
+ },
63
+ config: {
64
+ type: "string",
65
+ short: "c",
66
+ },
67
+ help: {
68
+ type: "boolean",
69
+ short: "h",
70
+ },
71
+ },
72
+ strict: true,
73
+ allowPositionals: true,
74
+ });
75
+
76
+ const options: CliOptions = {
77
+ input: "swagger.json",
78
+ angular: false,
79
+ http: false,
80
+ help: false,
81
+ };
82
+
83
+ if (values.input) {
84
+ options.input = values.input;
85
+ }
86
+ if (values.url) {
87
+ options.url = values.url;
88
+ }
89
+ if (values.angular) {
90
+ options.angular = values.angular;
91
+ }
92
+ if (values.http) {
93
+ options.http = values.http;
94
+ }
95
+ if (values.plugin) {
96
+ options.plugin = normalizePluginValues(values.plugin);
97
+ }
98
+ if (values.output) {
99
+ options.output = values.output;
100
+ }
101
+ if (values.config) {
102
+ options.config = values.config;
103
+ }
104
+ if (values.help) {
105
+ options.help = values.help;
106
+ }
107
+
108
+ for (const positional of positionals.slice(2)) {
109
+ if (positional === "init") {
110
+ continue;
111
+ }
112
+ if (positional.endsWith(".json")) {
113
+ options.input = positional;
114
+ }
115
+ }
116
+
117
+ return options;
118
+ }
119
+
120
+ /**
121
+ * Show help.
122
+ * @example
123
+ * ```ts
124
+ * showHelp();
125
+ * ```
126
+ */
127
+ export function showHelp(): void {
128
+ console.log(`
129
+ Sauron - OpenAPI to TypeScript/Angular Converter
130
+
131
+ USAGE:
132
+ sauron [COMMAND] [OPTIONS] [INPUT_FILE]
133
+
134
+ OPTIONS:
135
+ -i, --input <file> Input OpenAPI/Swagger JSON file (default: swagger.json)
136
+ -u, --url <url> Download OpenAPI/Swagger JSON from URL
137
+ -a, --angular Generate Angular service in src/app/sauron (requires Angular project)
138
+ -t, --http Generate HTTP client/service methods
139
+ -p, --plugin <id> HTTP plugin to run (repeatable: fetch, angular, axios)
140
+ -o, --output <dir> Output directory (default: outputs or src/app/sauron)
141
+ -c, --config <file> Config file path (default: sauron.config.ts)
142
+ -h, --help Show this help message
143
+
144
+ COMMANDS:
145
+ init Create sauron.config.ts with default settings
146
+
147
+ EXAMPLES:
148
+ sauron init
149
+ sauron --config ./sauron.config.ts
150
+ sauron swagger.json
151
+ sauron --input swaggerAfEstoque.json --angular --http
152
+ sauron --url https://api.example.com/swagger.json --http
153
+ sauron --http -i api.json -o ./generated
154
+ sauron --plugin fetch -i api.json
155
+ sauron --plugin axios -i api.json
156
+ sauron --plugin angular --plugin fetch -i api.json
157
+
158
+ When --angular flag is used, the tool will:
159
+ 1. Detect if current directory is an Angular project
160
+ 2. Generate models in src/app/sauron/models/
161
+ 3. Generate Angular service in src/app/sauron/sauron-api.service.ts
162
+
163
+ When --http flag is used without --angular:
164
+ 1. Generate fetch-based HTTP methods in outputs/http-client/
165
+ 2. Generate models in outputs/models/
166
+
167
+ Without flags, generates only TypeScript models.
168
+ `);
169
+ }
170
+
171
+ /**
172
+ * Normalize plugin values.
173
+ * @param pluginValues Input parameter `pluginValues`.
174
+ * @returns Normalize plugin values output as `string[]`.
175
+ * @example
176
+ * ```ts
177
+ * const result = normalizePluginValues(["fetch", "angular"]);
178
+ * // result: string[]
179
+ * ```
180
+ */
181
+ function normalizePluginValues(
182
+ pluginValues: string | string[],
183
+ ): string[] {
184
+ if (Array.isArray(pluginValues)) {
185
+ return pluginValues
186
+ .map((plugin) => plugin.trim())
187
+ .filter((plugin) => plugin.length > 0);
188
+ }
189
+
190
+ const normalizedPlugin = pluginValues.trim();
191
+ if (!normalizedPlugin) {
192
+ return [];
193
+ }
194
+
195
+ return [normalizedPlugin];
196
+ }
@@ -0,0 +1,228 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import type { z } from "zod";
5
+ import type { SwaggerOrOpenAPISchema } from "../schemas/swagger";
6
+ import { isAngularProject } from "./project";
7
+ import {
8
+ type CliOptions,
9
+ DEFAULT_CONFIG_FILE,
10
+ DEFAULT_SAURON_VERSION,
11
+ type SauronConfig,
12
+ } from "./types";
13
+
14
+ /**
15
+ * Format generated file.
16
+ * @param content Input parameter `content`.
17
+ * @param filePath Input parameter `filePath`.
18
+ * @returns Format generated file output as `Promise<string>`.
19
+ * @example
20
+ * ```ts
21
+ * const result = await formatGeneratedFile("value", "value");
22
+ * // result: string
23
+ * ```
24
+ */
25
+ export async function formatGeneratedFile(
26
+ content: string,
27
+ filePath: string,
28
+ ): Promise<string> {
29
+ try {
30
+ const prettier = await import("prettier");
31
+ return await prettier.format(content, { filepath: filePath });
32
+ } catch (error) {
33
+ console.warn(
34
+ `⚠️ Could not format ${filePath} with Prettier. Writing unformatted output.`,
35
+ error,
36
+ );
37
+ return content;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Get sauron version.
43
+ * @returns Get sauron version output as `string`.
44
+ * @example
45
+ * ```ts
46
+ * const result = getSauronVersion();
47
+ * // result: string
48
+ * ```
49
+ */
50
+ function getSauronVersion(): string {
51
+ try {
52
+ const packageJsonPath = resolve(
53
+ import.meta.dir,
54
+ "..",
55
+ "..",
56
+ "package.json",
57
+ );
58
+ const packageJsonContent = readFileSync(packageJsonPath, "utf-8");
59
+ const packageJson = JSON.parse(packageJsonContent) as { version?: unknown };
60
+ if (
61
+ typeof packageJson.version === "string" &&
62
+ packageJson.version.length > 0
63
+ ) {
64
+ return packageJson.version;
65
+ }
66
+ } catch {
67
+ // Fallback to default version when package metadata cannot be read.
68
+ }
69
+
70
+ return DEFAULT_SAURON_VERSION;
71
+ }
72
+
73
+ /**
74
+ * Create generated file header.
75
+ * @param schema Input parameter `schema`.
76
+ * @param generatedAt Input parameter `generatedAt`.
77
+ * @returns Create generated file header output as `string`.
78
+ * @example
79
+ * ```ts
80
+ * const result = createGeneratedFileHeader({}, {});
81
+ * // result: string
82
+ * ```
83
+ */
84
+ export function createGeneratedFileHeader(
85
+ schema: z.infer<typeof SwaggerOrOpenAPISchema>,
86
+ generatedAt = new Date().toISOString(),
87
+ ): string {
88
+ return `/**
89
+ * Gerado por Sauron v${getSauronVersion()}
90
+ * Timestamp: ${generatedAt}
91
+ * Nao edite manualmente.
92
+ * ${schema.info.title}
93
+ * OpenAPI spec version: ${schema.info.version}
94
+ */
95
+ `;
96
+ }
97
+
98
+ /**
99
+ * Init config file.
100
+ * @param configFilePath Input parameter `configFilePath`.
101
+ * @returns Init config file output as `Promise<void>`.
102
+ * @example
103
+ * ```ts
104
+ * const result = await initConfigFile({});
105
+ * // result: void
106
+ * ```
107
+ */
108
+ export async function initConfigFile(
109
+ configFilePath = DEFAULT_CONFIG_FILE,
110
+ ): Promise<void> {
111
+ const resolvedConfigPath = resolve(configFilePath);
112
+ if (existsSync(resolvedConfigPath)) {
113
+ console.warn(`⚠️ Config file already exists: ${configFilePath}`);
114
+ return;
115
+ }
116
+ const angularProjectDetected = isAngularProject();
117
+ const defaultOutput = angularProjectDetected ? "src/app/sauron" : "outputs";
118
+
119
+ const template = `import type { SauronConfig } from "@ygorazambuja/sauron";
120
+
121
+ export default {
122
+ // Use either "input" or "url". If both are set, "url" takes precedence.
123
+ input: "swagger.json",
124
+ // url: "https://example.com/openapi.json",
125
+ // plugin: ["fetch"],
126
+ output: "${defaultOutput}",
127
+ angular: ${angularProjectDetected},
128
+ http: true,
129
+ } satisfies SauronConfig;
130
+ `;
131
+
132
+ const formattedTemplate = await formatGeneratedFile(
133
+ template,
134
+ resolvedConfigPath,
135
+ );
136
+ writeFileSync(resolvedConfigPath, formattedTemplate);
137
+ console.log(`✅ Created config file: ${configFilePath}`);
138
+ }
139
+
140
+ /**
141
+ * Load sauron config.
142
+ * @param configFilePath Input parameter `configFilePath`.
143
+ * @returns Load sauron config output as `Promise<SauronConfig | null>`.
144
+ * @example
145
+ * ```ts
146
+ * const result = await loadSauronConfig({});
147
+ * // result: SauronConfig | null
148
+ * ```
149
+ */
150
+ export async function loadSauronConfig(
151
+ configFilePath = DEFAULT_CONFIG_FILE,
152
+ ): Promise<SauronConfig | null> {
153
+ const resolvedConfigPath = resolve(configFilePath);
154
+ if (!existsSync(resolvedConfigPath)) {
155
+ return null;
156
+ }
157
+
158
+ const configModule = await import(
159
+ `${pathToFileURL(resolvedConfigPath).href}?t=${Date.now()}`
160
+ );
161
+ const loadedConfig = configModule.default;
162
+
163
+ if (!loadedConfig || typeof loadedConfig !== "object") {
164
+ throw new Error(
165
+ `Invalid config file format in ${configFilePath}. Expected a default exported object.`,
166
+ );
167
+ }
168
+
169
+ return loadedConfig as SauronConfig;
170
+ }
171
+
172
+ /**
173
+ * Merge options with config.
174
+ * @param options Input parameter `options`.
175
+ * @param config Input parameter `config`.
176
+ * @returns Merge options with config output as `CliOptions`.
177
+ * @example
178
+ * ```ts
179
+ * const result = mergeOptionsWithConfig({}, {});
180
+ * // result: CliOptions
181
+ * ```
182
+ */
183
+ export function mergeOptionsWithConfig(
184
+ options: CliOptions,
185
+ config: SauronConfig,
186
+ ): CliOptions {
187
+ const mergedPlugins = resolveMergedPlugins(options.plugin, config.plugin);
188
+
189
+ return {
190
+ input:
191
+ options.input !== "swagger.json"
192
+ ? options.input
193
+ : (config.input ?? "swagger.json"),
194
+ url: options.url ?? config.url,
195
+ angular: options.angular || !!config.angular,
196
+ http: options.http || !!config.http,
197
+ plugin: mergedPlugins,
198
+ output: options.output ?? config.output,
199
+ config: options.config,
200
+ help: options.help,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Resolve merged plugins.
206
+ * @param cliPlugins Input parameter `cliPlugins`.
207
+ * @param configPlugins Input parameter `configPlugins`.
208
+ * @returns Resolve merged plugins output as `string[] | undefined`.
209
+ * @example
210
+ * ```ts
211
+ * const result = resolveMergedPlugins(["fetch"], ["angular"]);
212
+ * // result: string[] | undefined
213
+ * ```
214
+ */
215
+ function resolveMergedPlugins(
216
+ cliPlugins?: string[],
217
+ configPlugins?: string[],
218
+ ): string[] | undefined {
219
+ if (Array.isArray(cliPlugins) && cliPlugins.length > 0) {
220
+ return [...cliPlugins];
221
+ }
222
+
223
+ if (Array.isArray(configPlugins) && configPlugins.length > 0) {
224
+ return [...configPlugins];
225
+ }
226
+
227
+ return undefined;
228
+ }