From 9378fbef69cc4d32469a7ac30cbfa3d781784cca Mon Sep 17 00:00:00 2001 From: Anton Kolot Date: Thu, 11 Dec 2025 12:58:08 +0100 Subject: [PATCH 1/2] feat: add filterUndefined function to clean configuration object - Introduce filterUndefined function to remove undefined values from the configuration object - Update mergeConfig function to utilize filtered CLI config, ensuring only non-undefined values override file config values --- src/config.ts | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index fb5e245..3dcbdb7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,9 +22,40 @@ export function loadConfigFile(configPath: string): Config | null { } } +/** + * Filters out undefined values from a configuration object + * @param config - Configuration object to filter + * @returns Configuration object without undefined values + */ +function filterUndefined(config: Config): Partial { + const filtered: Partial = {}; + + if (config.input !== undefined) { + filtered.input = config.input; + } + if (config.output !== undefined) { + filtered.output = config.output; + } + if (config.clean !== undefined) { + filtered.clean = config.clean; + } + if (config.pretty !== undefined) { + filtered.pretty = config.pretty; + } + if (config.verbose !== undefined) { + filtered.verbose = config.verbose; + } + if (config.pathPrefixSkip !== undefined) { + filtered.pathPrefixSkip = config.pathPrefixSkip; + } + + return filtered; +} + /** * Merges CLI arguments with config file values (CLI takes precedence) - * @param cliConfig - Configuration from CLI arguments + * Only non-undefined CLI values override file config values + * @param cliConfig - Configuration from CLI arguments (may contain undefined values) * @param configPath - Optional path to config file * @returns Merged configuration */ @@ -42,10 +73,13 @@ export function mergeConfig(cliConfig: Config, configPath?: string): Config { fileConfig = loadConfigFile(defaultPath); } - // CLI config overrides file config + // Filter out undefined values from CLI config to avoid overwriting file config + const filteredCliConfig = filterUndefined(cliConfig); + + // CLI config overrides file config (only non-undefined values) return { - ...fileConfig, - ...cliConfig, + ...(fileConfig || {}), + ...filteredCliConfig, }; } From 1f7e4fb9783e3518ccd11e9ac5deaa02311e38a8 Mon Sep 17 00:00:00 2001 From: Anton Kolot Date: Thu, 11 Dec 2025 13:52:58 +0100 Subject: [PATCH 2/2] feat: add buildUrl utility function for path parameter replacement - Add buildUrl function to replace path parameters in endpoint URLs - Support for single and multiple path parameters - Type-safe parameter replacement with error handling - Update README with examples of using buildUrl - Remove unused placeholder variable --- README.md | 100 ++++++++++ src/cli.ts | 3 + src/config.ts | 16 +- src/generator/api-endpoints-generator.ts | 238 +++++++++++++++++++++++ src/types.ts | 2 + src/writer.ts | 72 ++++++- swagger-type-parser.config.json.example | 3 +- 7 files changed, 427 insertions(+), 7 deletions(-) create mode 100644 src/generator/api-endpoints-generator.ts diff --git a/README.md b/README.md index 8236565..18bee64 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A CLI tool to generate TypeScript type definitions from OpenAPI/Swagger specific - ✅ Generates types for schemas, request bodies, parameters, and responses - ✅ Includes JSDoc comments from OpenAPI descriptions - ✅ Configurable endpoint naming via path prefix skipping +- ✅ Generates API endpoint URL constants for type-safe API calls - ✅ Configurable via config file or CLI flags - ✅ Optional Prettier formatting for generated code @@ -79,6 +80,7 @@ npx @kolot/swagger-type-parser --config configs/swagger.config.json | `--pretty` | | Format generated code with Prettier | `false` | | `--verbose` | | Log verbose debug information | `false` | | `--path-prefix-skip` | | Number of path segment pairs to skip when generating endpoint names (e.g., 1 = skip first 2 segments: "/api/v1/auth/login" → "auth_login") | `0` | +| `--generate-api-endpoints` | | Generate API endpoint URL constants for easy access from frontend | `false` | **Note:** CLI flags override values from the config file. @@ -118,6 +120,8 @@ src/api/types/ │ │ ├── users_list.ts │ │ └── users_create.ts │ └── health.ts # Endpoints with single segment (e.g., /api/v1/health) +├── api/ # API endpoint URL constants (if --generate-api-endpoints is enabled) +│ └── index.ts # apiEndpoints object with nested structure └── common/ # Common utility types └── Http.ts # HttpMethod, RequestConfig, etc. ``` @@ -177,6 +181,101 @@ const result = await login('user@example.com', 'password123'); // result is typed as auth_login_200Response ``` +### Using API Endpoint Constants + +When `--generate-api-endpoints` flag is enabled, the tool generates a nested object structure with all API endpoint URLs, organized by their folder hierarchy. This eliminates the need to manually write URL strings: + +```typescript +import { apiEndpoints } from './api/types/api'; +import type { auth_login_200Response, auth_login_RequestBody } from './api/types/endpoints/auth/auth_login'; + +async function login(email: string, password: string): Promise { + const body: auth_login_RequestBody = { email, password }; + + const response = await fetch(apiEndpoints.auth.auth_login, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error('Login failed'); + } + + return response.json(); +} + +// Or with an API client +await apiClient.post( + apiEndpoints.auth.auth_login, + payload, + { + skipAuth: true, // Public endpoint + }, +); +``` + +### Handling Path Parameters + +For endpoints with path parameters (e.g., `/api/v1/users/profile/{user_id}`), use the `buildUrl` utility function to replace parameters: + +```typescript +import { apiEndpoints, buildUrl } from './api/types/api'; +import type { users_profile_200Response, users_profile_PathParams } from './api/types/endpoints/users/users_profile'; + +// Single parameter +const url = buildUrl(apiEndpoints.users.users_profile, { user_id: '123' }); +// Returns: '/api/v1/users/profile/123' + +// Multiple parameters +const url2 = buildUrl( + apiEndpoints.dynamic_fields.dynamic_fields_entities, + { entity_type: 'user_profile', entity_id: '456' } +); +// Returns: '/api/v1/dynamic-fields/entities/user_profile/456' + +// Type-safe usage with PathParams +async function getUserProfile(userId: string): Promise { + const params: users_profile_PathParams = { user_id: userId }; + const url = buildUrl(apiEndpoints.users.users_profile, params); + + const response = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + return response.json(); +} + +// Endpoints without parameters work as-is +const loginUrl = apiEndpoints.auth.auth_login; +// No need to use buildUrl for endpoints without parameters +``` + +The `apiEndpoints` object structure matches the endpoint folder organization: + +```typescript +// Generated structure example (with pathPrefixSkip: 1) +export const apiEndpoints = { + auth: { + auth_login: '/api/v1/auth/login', + auth_register: '/api/v1/auth/register', + auth_me: '/api/v1/auth/me', + }, + users: { + users_list: '/api/v1/users', + users_detail: '/api/v1/users/{id}', + }, + // ... +} as const; +``` + +**Benefits:** +- ✅ Type-safe endpoint URLs (autocomplete support) +- ✅ No manual URL string writing +- ✅ Automatic updates when API changes +- ✅ Consistent with endpoint type organization + ### Generated Type Naming Convention - **Schemas**: Use the schema name from `components.schemas` (e.g., `User`, `Order`) @@ -225,6 +324,7 @@ The config file (`swagger-type-parser.config.json`) supports the following optio - **`pretty`** (optional): Format generated code with Prettier (requires Prettier to be installed) - **`verbose`** (optional): Enable verbose logging - **`pathPrefixSkip`** (optional): Number of path segment pairs to skip when generating endpoint names +- **`generateApiEndpoints`** (optional): Generate API endpoint URL constants for easy access from frontend - `0` (default): Use full path - `/api/v1/auth/login` → `api_v1_auth_login` - `1`: Skip first 2 segments - `/api/v1/auth/login` → `auth_login` - `2`: Skip first 4 segments - `/api/v1/auth/login` → (empty, would be `root`) diff --git a/src/cli.ts b/src/cli.ts index 98e9fda..c4d9d89 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,6 +31,7 @@ async function main(): Promise { .option('--pretty', 'Format generated code with Prettier', false) .option('--verbose', 'Log verbose debug information', false) .option('--path-prefix-skip ', 'Number of path segments to skip from beginning (e.g., 1 = skip first 2 segments: "/api/v1/auth/login" -> "auth_login")', (value) => parseInt(value, 10)) + .option('--generate-api-endpoints', 'Generate API endpoint URL constants for easy access from frontend', false) .parse(process.argv); const options = program.opts(); @@ -45,6 +46,7 @@ async function main(): Promise { pretty: options.pretty, verbose: options.verbose, pathPrefixSkip: options.pathPrefixSkip, + generateApiEndpoints: options.generateApiEndpoints, }, options.config ); @@ -91,6 +93,7 @@ async function main(): Promise { pretty: config.pretty, verbose: config.verbose, pathPrefixSkip: config.pathPrefixSkip, + generateApiEndpoints: config.generateApiEndpoints, }); console.log(`✅ Successfully generated TypeScript types in ${config.output}`); diff --git a/src/config.ts b/src/config.ts index 3dcbdb7..6957d9f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,10 +22,20 @@ export function loadConfigFile(configPath: string): Config | null { } } +/** + * Checks if a flag was explicitly provided in CLI arguments + * @param flagName - Flag name (e.g., '--generate-api-endpoints') + * @returns True if flag was explicitly provided + */ +function isFlagProvided(flagName: string): boolean { + return process.argv.includes(flagName); +} + /** * Filters out undefined values from a configuration object + * For boolean flags, only includes them if they were explicitly provided in CLI * @param config - Configuration object to filter - * @returns Configuration object without undefined values + * @returns Configuration object without undefined values and non-explicit boolean flags */ function filterUndefined(config: Config): Partial { const filtered: Partial = {}; @@ -48,6 +58,10 @@ function filterUndefined(config: Config): Partial { if (config.pathPrefixSkip !== undefined) { filtered.pathPrefixSkip = config.pathPrefixSkip; } + // For boolean flags, only include if explicitly provided in CLI + if (config.generateApiEndpoints !== undefined && isFlagProvided('--generate-api-endpoints')) { + filtered.generateApiEndpoints = config.generateApiEndpoints; + } return filtered; } diff --git a/src/generator/api-endpoints-generator.ts b/src/generator/api-endpoints-generator.ts new file mode 100644 index 0000000..ec9db12 --- /dev/null +++ b/src/generator/api-endpoints-generator.ts @@ -0,0 +1,238 @@ +/** + * API endpoints URL constants generator + * + * This module generates TypeScript constants for API endpoint URLs, + * organized in a nested object structure matching the endpoint folder hierarchy. + * This allows frontend developers to use typed endpoint paths instead of + * manually writing URL strings. + */ + +import type { NormalizedSpec, Config } from '../types.js'; +import { generateEndpointTypes } from './endpoint-generator.js'; + +/** + * Represents the structure of API endpoints organized by folder hierarchy + */ +interface ApiEndpointsStructure { + [key: string]: string | ApiEndpointsStructure; +} + +/** + * Generates API endpoint URL constants from OpenAPI specification + * + * Creates a nested object structure where endpoints are organized by their + * folder paths (excluding the last segment), matching the endpoint type structure. + * + * @param spec - Normalized OpenAPI specification + * @param config - Configuration options (including pathPrefixSkip) + * @returns TypeScript code for API endpoints constants + * + * @example + * ```typescript + * // Generated structure: + * export const apiEndpoints = { + * auth: { + * auth_login: '/api/v1/auth/login', + * auth_register: '/api/v1/auth/register', + * }, + * users: { + * users_list: '/api/v1/users', + * }, + * }; + * ``` + */ +export function generateApiEndpoints(spec: NormalizedSpec, config: Config = {}): string { + const pathPrefixSkip = config.pathPrefixSkip || 0; + const endpoints = generateEndpointTypes(spec, config); + + // Build nested structure organized by folder paths + const structure: ApiEndpointsStructure = {}; + + for (const endpoint of endpoints) { + const folderPath = getEndpointFolderPath(endpoint.path, pathPrefixSkip); + const endpointName = endpoint.operationId; + const endpointPath = endpoint.path; + + // Navigate/create nested structure + let current = structure; + + if (folderPath) { + // Split folder path into segments + const folderSegments = folderPath.split('/').filter(Boolean); + + // Create nested objects for each folder segment + for (const segment of folderSegments) { + if (!current[segment] || typeof current[segment] === 'string') { + current[segment] = {}; + } + current = current[segment] as ApiEndpointsStructure; + } + } + + // Add endpoint path to the current level + current[endpointName] = endpointPath; + } + + // Generate TypeScript code from structure + return generateTypeScriptCode(structure); +} + +/** + * Gets folder path for endpoint based on path segments (all except last) + * This matches the logic used in writer.ts for endpoint folder organization + * + * @param path - API path + * @param pathPrefixSkip - Number of path segments to skip + * @returns Folder path (e.g., "auth" for "/api/v1/auth/login" with skip=1) + */ +function getEndpointFolderPath(path: string, pathPrefixSkip: number = 0): string { + if (path === '/' || path.trim() === '' || path.replace(/^\/+|\/+$/g, '') === '') { + return ''; + } + + const segments = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean); + const filteredSegments = segments.filter(segment => !segment.includes('{') && !segment.includes('}')); + const skipCount = pathPrefixSkip > 0 ? pathPrefixSkip * 2 : 0; + const skippedSegments = filteredSegments.slice(skipCount); + + // If no segments or only one segment, return empty (no subfolder) + if (skippedSegments.length <= 1) { + return ''; + } + + // Take all segments except the last one + const folderSegments = skippedSegments.slice(0, -1); + const processedSegments = folderSegments.map(segment => segment.replace(/-/g, '_')); + + return processedSegments.join('/').toLowerCase(); +} + +/** + * Generates TypeScript code from nested structure + * + * @param structure - Nested object structure + * @param indent - Current indentation level + * @returns TypeScript code string + */ +function generateTypeScriptCode(structure: ApiEndpointsStructure, indent: number = 0): string { + const nextIndent = indent + 1; + const nextIndentStr = ' '.repeat(nextIndent); + + const lines: string[] = []; + const entries = Object.entries(structure).sort(([a], [b]) => a.localeCompare(b)); + + for (let i = 0; i < entries.length; i++) { + const [key, value] = entries[i]; + const isLast = i === entries.length - 1; + + if (typeof value === 'string') { + // Leaf node - endpoint path + const comma = isLast ? '' : ','; + lines.push(`${nextIndentStr}${key}: ${JSON.stringify(value)}${comma}`); + } else { + // Nested object - folder + const comma = isLast ? '' : ','; + lines.push(`${nextIndentStr}${key}: {`); + lines.push(generateTypeScriptCode(value, nextIndent)); + lines.push(`${nextIndentStr}}${comma}`); + } + } + + return lines.join('\n'); +} + +/** + * Generates the complete API endpoints file content + * + * @param spec - Normalized OpenAPI specification + * @param config - Configuration options + * @returns Complete TypeScript file content + */ +export function generateApiEndpointsFile(spec: NormalizedSpec, config: Config = {}): string { + const structureCode = generateApiEndpoints(spec, config); + + return `/** + * API Endpoint URL Constants + * + * This file contains all API endpoint URLs organized by their folder structure. + * Use these constants instead of manually writing URL strings for type safety and maintainability. + * + * @example + * \`\`\`typescript + * import { apiEndpoints } from './api'; + * + * await apiClient.post( + * apiEndpoints.auth.auth_login, + * payload, + * { skipAuth: true } + * ); + * \`\`\` + */ + +export const apiEndpoints = { +${structureCode} +} as const; + +/** + * Type helper for API endpoints structure + */ +export type ApiEndpoints = typeof apiEndpoints; + +/** + * Replaces path parameters in a URL template with actual values + * + * This utility function helps build complete URLs from endpoint templates + * that contain path parameters (e.g., '/api/v1/users/{user_id}'). + * + * @param urlTemplate - URL template with parameters in curly braces (e.g., '/api/v1/users/{user_id}') + * @param params - Object with parameter values (e.g., { user_id: '123' }) + * @returns Complete URL with parameters replaced (e.g., '/api/v1/users/123') + * + * @example + * \`\`\`typescript + * import { apiEndpoints, buildUrl } from './api'; + * + * // Simple single parameter + * const url = buildUrl(apiEndpoints.users.users_profile, { user_id: '123' }); + * // Returns: '/api/v1/users/profile/123' + * + * // Multiple parameters + * const url2 = buildUrl( + * apiEndpoints.dynamic_fields.dynamic_fields_entities, + * { entity_type: 'user_profile', entity_id: '456' } + * ); + * // Returns: '/api/v1/dynamic-fields/entities/user_profile/456' + * + * // No parameters (returns as-is) + * const url3 = buildUrl(apiEndpoints.auth.auth_login, {}); + * // Returns: '/api/v1/auth/login' + * \`\`\` + */ +export function buildUrl>( + urlTemplate: string, + params: T +): string { + let url = urlTemplate; + + // Replace each parameter in the template + for (const [key, value] of Object.entries(params)) { + // Replace all occurrences of {key} with the value + const regex = new RegExp('\\\\{' + key + '\\\\}', 'g'); + url = url.replace(regex, String(value)); + } + + // Check if there are any remaining placeholders + const remainingPlaceholders = url.match(/\\{[^}]+\\}/g); + if (remainingPlaceholders && remainingPlaceholders.length > 0) { + const missing = remainingPlaceholders.map(p => p.slice(1, -1)).join(', '); + throw new Error( + 'Missing required path parameters: ' + missing + '. ' + + 'Provided params: ' + Object.keys(params).join(', ') + ); + } + + return url; +} +`; +} + diff --git a/src/types.ts b/src/types.ts index b0b36f1..708e48f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,8 @@ export interface Config { verbose?: boolean; /** Number of path segments to skip from the beginning when generating endpoint names (e.g., 1 = skip first 2 segments: "/api/v1/auth/login" -> "auth_login") */ pathPrefixSkip?: number; + /** Whether to generate API endpoint URL constants */ + generateApiEndpoints?: boolean; } /** diff --git a/src/writer.ts b/src/writer.ts index 06b0373..03df87f 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -3,6 +3,7 @@ import { join, dirname, relative, resolve } from 'path'; import { existsSync } from 'fs'; import type { TypeDefinition, NormalizedSpec } from './types.js'; import { pathToEndpointName } from './generator/index.js'; +import { generateApiEndpointsFile } from './generator/api-endpoints-generator.js'; /** * Writes generated TypeScript types to the file system @@ -15,7 +16,7 @@ export async function writeTypes( outputDir: string, types: Map, spec: NormalizedSpec, - options: { clean?: boolean; pretty?: boolean; verbose?: boolean; pathPrefixSkip?: number } + options: { clean?: boolean; pretty?: boolean; verbose?: boolean; pathPrefixSkip?: number; generateApiEndpoints?: boolean } ): Promise { // Clean output directory if requested if (options.clean && existsSync(outputDir)) { @@ -29,10 +30,16 @@ export async function writeTypes( const schemasDir = join(outputDir, 'schemas'); const endpointsDir = join(outputDir, 'endpoints'); const commonDir = join(outputDir, 'common'); + const apiDir = join(outputDir, 'api'); await mkdir(schemasDir, { recursive: true }); await mkdir(endpointsDir, { recursive: true }); await mkdir(commonDir, { recursive: true }); + + // Create API directory if endpoints generation is enabled + if (options.generateApiEndpoints) { + await mkdir(apiDir, { recursive: true }); + } // Write common types await writeCommonTypes(commonDir); @@ -85,8 +92,16 @@ export async function writeTypes( } } + // Write API endpoints if enabled + if (options.generateApiEndpoints) { + await writeApiEndpoints(apiDir, spec, options); + if (options.verbose) { + console.log(`Generated: ${join(apiDir, 'index.ts')}`); + } + } + // Write index files - await writeIndexFiles(outputDir, schemasDir, endpointsDir, schemaTypes, endpointsByFolder); + await writeIndexFiles(outputDir, schemasDir, endpointsDir, schemaTypes, endpointsByFolder, options.generateApiEndpoints); } /** @@ -158,6 +173,42 @@ export interface RequestConfig { await writeFile(join(commonDir, 'Http.ts'), httpTypes, 'utf-8'); } +/** + * Writes API endpoints constants file + * @param apiDir - API directory path + * @param spec - Normalized specification + * @param options - Writer options + */ +async function writeApiEndpoints( + apiDir: string, + spec: NormalizedSpec, + options: { pretty?: boolean; pathPrefixSkip?: number } +): Promise { + const content = generateApiEndpointsFile(spec, { + pathPrefixSkip: options.pathPrefixSkip, + }); + + // Format with Prettier if requested + let finalContent = content; + if (options.pretty) { + try { + const prettier = await import('prettier'); + finalContent = await prettier.format(content, { + parser: 'typescript', + singleQuote: true, + semi: true, + trailingComma: 'es5', + printWidth: 100, + }); + } catch { + // Prettier not available, use as-is + finalContent = content; + } + } + + await writeFile(join(apiDir, 'index.ts'), finalContent, 'utf-8'); +} + /** * Writes index files for easy imports * @param outputDir - Root output directory @@ -165,13 +216,15 @@ export interface RequestConfig { * @param endpointsDir - Endpoints directory * @param schemaTypes - All schema types * @param endpointsByFolder - Endpoints grouped by folder path + * @param generateApiEndpoints - Whether API endpoints were generated */ async function writeIndexFiles( outputDir: string, schemasDir: string, endpointsDir: string, schemaTypes: Map, - endpointsByFolder: Map> + endpointsByFolder: Map>, + generateApiEndpoints?: boolean ): Promise { // Write schemas index const schemaExports: string[] = []; @@ -189,7 +242,7 @@ async function writeIndexFiles( } // Write main index - const mainIndex = [ + const mainIndexLines = [ "// Common types", "export * from './common/Http';", "", @@ -198,7 +251,16 @@ async function writeIndexFiles( "", "// Endpoint types", ...endpointExports, - ].join('\n'); + ]; + + // Add API endpoints export if generated + if (generateApiEndpoints) { + mainIndexLines.push(""); + mainIndexLines.push("// API endpoint URL constants"); + mainIndexLines.push("export * from './api';"); + } + + const mainIndex = mainIndexLines.join('\n'); await writeFile(join(outputDir, 'index.ts'), mainIndex, 'utf-8'); } diff --git a/swagger-type-parser.config.json.example b/swagger-type-parser.config.json.example index 1558a5f..17b0588 100644 --- a/swagger-type-parser.config.json.example +++ b/swagger-type-parser.config.json.example @@ -4,6 +4,7 @@ "clean": true, "pretty": true, "verbose": false, - "pathPrefixSkip": 1 + "pathPrefixSkip": 1, + "generateApiEndpoints": true }