Skip to content

Commit 4e2d695

Browse files
committed
feat: implement fetch client generation and parsing of OpenAPI specifications
1 parent 47960c8 commit 4e2d695

4 files changed

Lines changed: 330 additions & 10 deletions

File tree

api-client.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
class ApiClient {
2+
constructor(baseUrl = '', options = {}) {
3+
this.baseUrl = baseUrl;
4+
this.defaultOptions = {
5+
headers: {
6+
'Content-Type': 'application/json',
7+
...options.headers
8+
},
9+
...options
10+
};
11+
}
12+
13+
async request(path, options = {}) {
14+
const url = this.baseUrl + path;
15+
const config = {
16+
...this.defaultOptions,
17+
...options,
18+
headers: {
19+
...this.defaultOptions.headers,
20+
...options.headers
21+
}
22+
};
23+
24+
const response = await fetch(url, config);
25+
26+
if (!response.ok) {
27+
throw new Error(`HTTP error! status: ${response.status}`);
28+
}
29+
30+
const contentType = response.headers.get('content-type');
31+
if (contentType && contentType.includes('application/json')) {
32+
return await response.json();
33+
} else if (contentType && contentType.includes('text/')) {
34+
return await response.text();
35+
} else {
36+
return response;
37+
}
38+
}
39+
40+
async token() {
41+
return await this.request('/authentication/token', {
42+
method: 'POST'
43+
});
44+
}
45+
46+
async logout(data) {
47+
return await this.request('/authentication/logout', {
48+
method: 'POST',
49+
body: JSON.stringify(data)
50+
});
51+
}
52+
53+
async getAuthenticationPing() {
54+
return await this.request('/authentication/ping', {
55+
method: 'GET'
56+
});
57+
}
58+
59+
async getApiOrganisations() {
60+
return await this.request('/api/organisations', {
61+
method: 'GET'
62+
});
63+
}
64+
65+
}
66+
67+
export default ApiClient;

bin/cli.js

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
#!/usr/bin/env node
22

33
import { program } from 'commander';
4-
import { readFileSync } from 'fs';
4+
import { readFileSync, writeFileSync } from 'fs';
55
import { fileURLToPath } from 'url';
66
import path from 'path';
7+
import { generate } from '../index.js';
78

89
const __filename = fileURLToPath(import.meta.url);
910
const __dirname = path.dirname(__filename);
@@ -20,10 +21,29 @@ program
2021
.option('-i, --input <file>', 'input specification file')
2122
.option('-o, --output <file>', 'output file path')
2223
.action((options) => {
23-
console.log('Generating fetch client...');
24-
console.log('Input:', options.input);
25-
console.log('Output:', options.output);
26-
// Implementation will go here
24+
if (!options.input) {
25+
console.error('Error: Input file is required');
26+
process.exit(1);
27+
}
28+
29+
if (!options.output) {
30+
console.error('Error: Output file is required');
31+
process.exit(1);
32+
}
33+
34+
try {
35+
console.log('Generating fetch client...');
36+
console.log('Input:', options.input);
37+
console.log('Output:', options.output);
38+
39+
const clientCode = generate(options.input);
40+
writeFileSync(options.output, clientCode, 'utf8');
41+
42+
console.log('✓ Fetch client generated successfully!');
43+
} catch (error) {
44+
console.error('Error generating client:', error.message);
45+
process.exit(1);
46+
}
2747
});
2848

2949
program.parse();

index.js

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,141 @@
1-
// fetch-client-generator main entry point
1+
import { readFileSync } from 'fs';
2+
3+
export function parseOpenAPISpec(specPath) {
4+
const spec = JSON.parse(readFileSync(specPath, 'utf8'));
5+
6+
const endpoints = [];
7+
8+
for (const [path, pathItem] of Object.entries(spec.paths)) {
9+
for (const [method, operation] of Object.entries(pathItem)) {
10+
const endpoint = {
11+
path,
12+
method: method.toUpperCase(),
13+
operationId: operation.operationId,
14+
requestBody: operation.requestBody,
15+
responses: operation.responses,
16+
tags: operation.tags
17+
};
18+
endpoints.push(endpoint);
19+
}
20+
}
21+
22+
return {
23+
info: spec.info,
24+
endpoints,
25+
schemas: spec.components?.schemas || {}
26+
};
27+
}
28+
29+
export function generateFetchClient(parsedSpec, options = {}) {
30+
const { info, endpoints, schemas } = parsedSpec;
31+
const className = options.className || 'ApiClient';
32+
33+
let clientCode = `class ${className} {
34+
constructor(baseUrl = '', options = {}) {
35+
this.baseUrl = baseUrl;
36+
this.defaultOptions = {
37+
headers: {
38+
'Content-Type': 'application/json',
39+
...options.headers
40+
},
41+
...options
42+
};
43+
}
44+
45+
async request(path, options = {}) {
46+
const url = this.baseUrl + path;
47+
const config = {
48+
...this.defaultOptions,
49+
...options,
50+
headers: {
51+
...this.defaultOptions.headers,
52+
...options.headers
53+
}
54+
};
55+
56+
const response = await fetch(url, config);
57+
58+
if (!response.ok) {
59+
throw new Error(\`HTTP error! status: \${response.status}\`);
60+
}
61+
62+
const contentType = response.headers.get('content-type');
63+
if (contentType && contentType.includes('application/json')) {
64+
return await response.json();
65+
} else if (contentType && contentType.includes('text/')) {
66+
return await response.text();
67+
} else {
68+
return response;
69+
}
70+
}
71+
72+
`;
73+
74+
for (const endpoint of endpoints) {
75+
let methodName = endpoint.operationId;
76+
77+
if (!methodName) {
78+
// Generate camelCase method name from path and method
79+
const pathParts = endpoint.path.split('/').filter(part => part && !part.startsWith('{'));
80+
// Convert each part to PascalCase and join
81+
const pascalParts = pathParts.map(part => part.charAt(0).toUpperCase() + part.slice(1));
82+
const cleanPath = pascalParts.join('');
83+
methodName = `${endpoint.method.toLowerCase()}${cleanPath}`;
84+
}
85+
86+
// Convert to camelCase if not already
87+
methodName = methodName.charAt(0).toLowerCase() + methodName.slice(1);
88+
89+
const hasRequestBody = endpoint.requestBody &&
90+
endpoint.requestBody.content &&
91+
Object.keys(endpoint.requestBody.content).length > 0;
92+
93+
const params = hasRequestBody ? 'data' : '';
94+
const methodParams = params ? `(${params})` : '()';
95+
96+
clientCode += ` async ${methodName}${methodParams} {
97+
`;
98+
99+
if (hasRequestBody) {
100+
clientCode += ` return await this.request('${endpoint.path}', {
101+
method: '${endpoint.method}',
102+
body: JSON.stringify(data)
103+
});
104+
`;
105+
} else {
106+
clientCode += ` return await this.request('${endpoint.path}', {
107+
method: '${endpoint.method}'
108+
});
109+
`;
110+
}
111+
112+
clientCode += ` }
113+
114+
`;
115+
}
116+
117+
clientCode += `}
118+
119+
export default ${className};`;
120+
121+
return clientCode;
122+
}
123+
124+
export function generate(inputPath, outputPath, options = {}) {
125+
const parsedSpec = parseOpenAPISpec(inputPath);
126+
const clientCode = generateFetchClient(parsedSpec, options);
127+
128+
if (outputPath) {
129+
import('fs').then(({ writeFileSync }) => {
130+
writeFileSync(outputPath, clientCode, 'utf8');
131+
});
132+
}
133+
134+
return clientCode;
135+
}
136+
2137
export default {
3-
// Main functionality will be implemented here
138+
parseOpenAPISpec,
139+
generateFetchClient,
140+
generate
4141
};

test/index.test.js

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,104 @@
11
import { expect } from 'chai';
2-
import fetchClientGenerator from '../index.js';
2+
import { parseOpenAPISpec, generateFetchClient, generate } from '../index.js';
3+
import { writeFileSync, unlinkSync } from 'fs';
4+
import path from 'path';
5+
6+
const sampleOpenAPI = {
7+
"openapi": "3.0.1",
8+
"info": {
9+
"title": "Test API",
10+
"version": "1.0.0"
11+
},
12+
"paths": {
13+
"/users": {
14+
"get": {
15+
"operationId": "getUsers",
16+
"responses": {
17+
"200": {
18+
"description": "OK",
19+
"content": {
20+
"application/json": {
21+
"schema": {
22+
"type": "array",
23+
"items": {
24+
"type": "object"
25+
}
26+
}
27+
}
28+
}
29+
}
30+
}
31+
},
32+
"post": {
33+
"operationId": "createUser",
34+
"requestBody": {
35+
"content": {
36+
"application/json": {
37+
"schema": {
38+
"type": "object",
39+
"properties": {
40+
"name": { "type": "string" }
41+
}
42+
}
43+
}
44+
}
45+
},
46+
"responses": {
47+
"201": {
48+
"description": "Created"
49+
}
50+
}
51+
}
52+
}
53+
}
54+
};
355

456
describe('fetch-client-generator', () => {
5-
it('should export an object', () => {
6-
expect(fetchClientGenerator).to.be.an('object');
57+
let testSpecPath;
58+
59+
beforeEach(() => {
60+
testSpecPath = path.join(process.cwd(), 'test-spec.json');
61+
writeFileSync(testSpecPath, JSON.stringify(sampleOpenAPI, null, 2));
62+
});
63+
64+
afterEach(() => {
65+
try {
66+
unlinkSync(testSpecPath);
67+
} catch (err) {
68+
// File might not exist
69+
}
70+
});
71+
72+
describe('parseOpenAPISpec', () => {
73+
it('should parse OpenAPI specification', () => {
74+
const result = parseOpenAPISpec(testSpecPath);
75+
76+
expect(result).to.have.property('info');
77+
expect(result).to.have.property('endpoints');
78+
expect(result).to.have.property('schemas');
79+
expect(result.info.title).to.equal('Test API');
80+
expect(result.endpoints).to.have.length(2);
81+
});
82+
});
83+
84+
describe('generateFetchClient', () => {
85+
it('should generate fetch client code', () => {
86+
const parsedSpec = parseOpenAPISpec(testSpecPath);
87+
const clientCode = generateFetchClient(parsedSpec);
88+
89+
expect(clientCode).to.include('class ApiClient');
90+
expect(clientCode).to.include('async getUsers()');
91+
expect(clientCode).to.include('async createUser(data)');
92+
expect(clientCode).to.include('fetch(url, config)');
93+
});
94+
});
95+
96+
describe('generate', () => {
97+
it('should generate and return client code', () => {
98+
const clientCode = generate(testSpecPath);
99+
100+
expect(clientCode).to.be.a('string');
101+
expect(clientCode).to.include('class ApiClient');
102+
});
7103
});
8104
});

0 commit comments

Comments
 (0)