Skip to content

Commit e47f84d

Browse files
committed
feat: add open api spec type generation
0 parents  commit e47f84d

File tree

12 files changed

+414
-0
lines changed

12 files changed

+414
-0
lines changed

.gitignore

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Build output
2+
dist/
3+
test/generated/
4+
5+
# Dependencies
6+
node_modules/
7+
8+
# IDE files
9+
.vscode/
10+
.idea/
11+
*.sublime-*
12+
13+
# Logs
14+
logs/
15+
*.log
16+
npm-debug.log*
17+
yarn-debug.log*
18+
yarn-error.log*
19+
20+
# Environment variables
21+
.env
22+
.env.local
23+
.env.*.local
24+
25+
# OS files
26+
.DS_Store
27+
Thumbs.db
28+
29+
# Test coverage
30+
coverage/
31+
32+
# TypeScript cache
33+
*.tsbuildinfo
34+
35+
# npm package files
36+
package-lock.json
37+
yarn.lock

example-config.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"specSource": "https://api.example.com/swagger.json",
3+
"exportDir": "./generated",
4+
"options": {
5+
"generateMocks": true,
6+
"includeJSDocs": true
7+
}
8+
}

package.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "openapi-axios-generator",
3+
"version": "1.0.0",
4+
"description": "Generate Axios API clients from OpenAPI specifications",
5+
"main": "dist/index.js",
6+
"bin": {
7+
"generate-api": "./dist/cli.js"
8+
},
9+
"scripts": {
10+
"build": "tsc",
11+
"start": "node dist/cli.js",
12+
"test": "tsc -p test/tsconfig.json && node dist/test/index.js"
13+
},
14+
"dependencies": {
15+
"axios": "^1.6.0",
16+
"openapi-types": "^12.1.3",
17+
"yaml": "^2.3.4"
18+
},
19+
"devDependencies": {
20+
"@types/node": "^20.8.0",
21+
"typescript": "^5.2.2"
22+
}
23+
}

src/cli.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env node
2+
3+
import { resolve } from 'path';
4+
import { loadConfig } from './config/loadConfig';
5+
import { generateApiClient } from './generator/apiGenerator';
6+
7+
async function main() {
8+
try {
9+
const configPath = process.argv[2];
10+
11+
if (!configPath) {
12+
console.error('Please provide a path to your configuration file');
13+
process.exit(1);
14+
}
15+
16+
const resolvedConfigPath = resolve(process.cwd(), configPath);
17+
const config = await loadConfig(resolvedConfigPath);
18+
19+
await generateApiClient(config);
20+
21+
console.log('API client generated successfully!');
22+
} catch (error) {
23+
if (error instanceof Error) {
24+
console.error('Error:', error.message);
25+
} else {
26+
console.error('Error: Unknown error occurred');
27+
}
28+
process.exit(1);
29+
}
30+
}
31+
32+
main();

src/config/loadConfig.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { readFile } from 'fs/promises';
2+
import { OpenAPIConfig } from '../types/config';
3+
4+
export async function loadConfig(configPath: string): Promise<OpenAPIConfig> {
5+
try {
6+
const configContent = await readFile(configPath, 'utf-8');
7+
const config = JSON.parse(configContent) as OpenAPIConfig;
8+
9+
// Validate required fields
10+
if (!config.specSource) {
11+
throw new Error('specSource is required in configuration');
12+
}
13+
14+
if (!config.exportDir) {
15+
throw new Error('exportDir is required in configuration');
16+
}
17+
18+
// Set default options
19+
config.options = {
20+
generateMocks: true,
21+
includeJSDocs: true,
22+
...config.options
23+
};
24+
25+
return config;
26+
} catch (error) {
27+
if (error instanceof Error) {
28+
throw new Error(`Failed to load configuration: ${error.message}`);
29+
}
30+
throw new Error('Failed to load configuration: Unknown error');
31+
}
32+
}

src/generator/apiGenerator.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { OpenAPIConfig } from '../types/config';
2+
import { readFile, mkdir, writeFile } from 'fs/promises';
3+
import { resolve } from 'path';
4+
import axios from 'axios';
5+
import * as yaml from 'yaml';
6+
import { OpenAPIV3 } from 'openapi-types';
7+
import { generateTypeDefinitions } from './schemaGenerator';
8+
9+
/**
10+
* Loads the OpenAPI specification from either a URL or local file
11+
*/
12+
async function loadOpenAPISpec(specSource: string): Promise<OpenAPIV3.Document> {
13+
try {
14+
if (specSource.startsWith('http')) {
15+
const response = await axios.get(specSource);
16+
return response.data;
17+
} else {
18+
const content = await readFile(specSource, 'utf-8');
19+
// Handle both JSON and YAML formats
20+
return specSource.endsWith('.json')
21+
? JSON.parse(content)
22+
: yaml.parse(content);
23+
}
24+
} catch (error) {
25+
if (error instanceof Error) {
26+
throw new Error(`Failed to load OpenAPI spec: ${error.message}`);
27+
}
28+
throw new Error('Failed to load OpenAPI spec: Unknown error');
29+
}
30+
}
31+
32+
/**
33+
* Main function to generate the API client
34+
*/
35+
export async function generateApiClient(config: OpenAPIConfig): Promise<void> {
36+
try {
37+
// Load the OpenAPI specification
38+
const spec = await loadOpenAPISpec(config.specSource);
39+
40+
// Create export directory if it doesn't exist
41+
await mkdir(config.exportDir, { recursive: true });
42+
43+
// Generate and write type definitions
44+
const typeDefinitions = generateTypeDefinitions(spec);
45+
await writeFile(
46+
resolve(config.exportDir, 'types.ts'),
47+
typeDefinitions,
48+
'utf-8'
49+
);
50+
51+
console.log('Generated TypeScript interfaces successfully');
52+
53+
// TODO: Implement remaining steps
54+
// 1. Generate Axios client methods for each path
55+
// 2. Generate mock data if enabled
56+
// 3. Add JSDoc comments if enabled
57+
58+
} catch (error) {
59+
if (error instanceof Error) {
60+
throw new Error(`Failed to generate API client: ${error.message}`);
61+
}
62+
throw new Error('Failed to generate API client: Unknown error');
63+
}
64+
}

src/generator/schemaGenerator.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { OpenAPIV3 } from 'openapi-types';
2+
3+
interface SchemaContext {
4+
schemas: { [key: string]: OpenAPIV3.SchemaObject };
5+
generatedTypes: Set<string>;
6+
}
7+
8+
/**
9+
* Converts OpenAPI schema type to TypeScript type
10+
*/
11+
function getTypeFromSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, context: SchemaContext): string {
12+
if (!schema) return 'any';
13+
14+
if ('$ref' in schema) {
15+
const refType = schema.$ref.split('/').pop()!;
16+
return refType;
17+
}
18+
19+
// Handle enum types properly
20+
if (schema.enum) {
21+
return schema.enum.map(e => typeof e === 'string' ? `'${e}'` : e).join(' | ');
22+
}
23+
24+
switch (schema.type) {
25+
case 'string':
26+
return 'string';
27+
case 'number':
28+
case 'integer':
29+
return 'number';
30+
case 'boolean':
31+
return 'boolean';
32+
case 'array':
33+
const itemType = getTypeFromSchema(schema.items as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, context);
34+
return `Array<${itemType}>`;
35+
case 'object':
36+
if (schema.properties) {
37+
const properties = Object.entries(schema.properties)
38+
.map(([key, prop]) => {
39+
const isRequired = schema.required?.includes(key);
40+
const propertyType = getTypeFromSchema(prop as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, context);
41+
return ` ${key}${isRequired ? '' : '?'}: ${propertyType};`;
42+
})
43+
.join('\n');
44+
return `{\n${properties}\n}`;
45+
}
46+
if (schema.additionalProperties) {
47+
const valueType = typeof schema.additionalProperties === 'boolean'
48+
? 'any'
49+
: getTypeFromSchema(schema.additionalProperties as OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, context);
50+
return `Record<string, ${valueType}>`;
51+
}
52+
return 'Record<string, any>';
53+
default:
54+
return 'any';
55+
}
56+
}
57+
58+
function generateTypeDefinition(name: string, schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, context: SchemaContext): string {
59+
const description = !('$ref' in schema) && schema.description
60+
? `/**\n * ${schema.description}\n */\n`
61+
: '';
62+
63+
const typeValue = getTypeFromSchema(schema, context);
64+
65+
// Use 'type' for primitives, unions, and simple types
66+
// Use 'interface' only for complex objects with properties
67+
const isInterface = !('$ref' in schema) && schema.type === 'object' && schema.properties;
68+
69+
return isInterface
70+
? `${description}export interface ${name} ${typeValue}\n\n`
71+
: `${description}export type ${name} = ${typeValue}\n\n`;
72+
}
73+
74+
/**
75+
* Generates TypeScript interface definitions from OpenAPI schemas
76+
*/
77+
export function generateTypeDefinitions(spec: OpenAPIV3.Document): string {
78+
const context: SchemaContext = {
79+
schemas: spec.components?.schemas as { [key: string]: OpenAPIV3.SchemaObject } || {},
80+
generatedTypes: new Set()
81+
};
82+
83+
let output = '/* Generated TypeScript Definitions */\n\n';
84+
85+
// Generate types for all schema definitions
86+
for (const [name, schema] of Object.entries(context.schemas)) {
87+
if (context.generatedTypes.has(name)) continue;
88+
output += generateTypeDefinition(name, schema, context);
89+
context.generatedTypes.add(name);
90+
}
91+
92+
// Generate request/response types
93+
if (spec.paths) {
94+
for (const [path, pathItem] of Object.entries(spec.paths)) {
95+
for (const [method, operation] of Object.entries(pathItem as OpenAPIV3.PathItemObject)) {
96+
if (method === '$ref') continue;
97+
98+
const operationObject = operation as OpenAPIV3.OperationObject;
99+
if (!operationObject) continue;
100+
101+
// Generate request body type
102+
if (operationObject.requestBody) {
103+
const content = (operationObject.requestBody as OpenAPIV3.RequestBodyObject).content;
104+
const jsonContent = content['application/json'];
105+
if (jsonContent?.schema) {
106+
const typeName = `${operationObject.operationId}Request`;
107+
output += generateTypeDefinition(typeName, jsonContent.schema as OpenAPIV3.SchemaObject, context);
108+
}
109+
}
110+
111+
// Generate response types
112+
if (operationObject.responses) {
113+
for (const [code, response] of Object.entries(operationObject.responses)) {
114+
const responseObj = response as OpenAPIV3.ResponseObject;
115+
const content = responseObj.content?.['application/json'];
116+
if (content?.schema) {
117+
const typeName = `${operationObject.operationId}Response${code}`;
118+
output += generateTypeDefinition(typeName, content.schema as OpenAPIV3.SchemaObject, context);
119+
}
120+
}
121+
}
122+
}
123+
}
124+
}
125+
126+
return output;
127+
}

src/types/config.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export interface OpenAPIConfig {
2+
/**
3+
* OpenAPI specification source - can be a URL or local file path
4+
* Supports JSON, YAML, or YML formats
5+
*/
6+
specSource: string;
7+
8+
/**
9+
* Directory where generated files will be exported
10+
*/
11+
exportDir: string;
12+
13+
/**
14+
* Optional configuration options
15+
*/
16+
options?: {
17+
/**
18+
* Generate mock data for API responses
19+
* @default true
20+
*/
21+
generateMocks?: boolean;
22+
23+
/**
24+
* Include JSDoc comments in generated code
25+
* @default true
26+
*/
27+
includeJSDocs?: boolean;
28+
};
29+
}

test/configs/test-config.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"specSource": "https://api.jaarbeurs.acc.lightbase.nl/_compas/structure.json?format=openapi",
3+
"exportDir": "./test/generated",
4+
"options": {
5+
"generateMocks": true,
6+
"includeJSDocs": true
7+
}
8+
}

0 commit comments

Comments
 (0)