fetch-client-generator 1.0.1 → 1.2.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.
- package/api-client.js +67 -0
- package/bin/cli.js +60 -5
- package/index.js +139 -2
- package/package.json +1 -1
- package/test/cli.test.js +112 -1
- package/test/index.test.js +99 -3
package/api-client.js
ADDED
|
@@ -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;
|
package/bin/cli.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { program } from 'commander';
|
|
4
|
-
import { readFileSync } from 'fs';
|
|
4
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
import path from 'path';
|
|
7
|
+
import chokidar from 'chokidar';
|
|
8
|
+
import { generate } from '../index.js';
|
|
7
9
|
|
|
8
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
11
|
const __dirname = path.dirname(__filename);
|
|
@@ -19,11 +21,64 @@ program
|
|
|
19
21
|
.description('Generate fetch client code')
|
|
20
22
|
.option('-i, --input <file>', 'input specification file')
|
|
21
23
|
.option('-o, --output <file>', 'output file path')
|
|
24
|
+
.option('-w, --watch', 'watch input file for changes and regenerate automatically')
|
|
22
25
|
.action((options) => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
if (!options.input) {
|
|
27
|
+
console.error('Error: Input file is required');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!options.output) {
|
|
32
|
+
console.error('Error: Output file is required');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const generateClient = () => {
|
|
37
|
+
try {
|
|
38
|
+
console.log('Generating fetch client...');
|
|
39
|
+
console.log('Input:', options.input);
|
|
40
|
+
console.log('Output:', options.output);
|
|
41
|
+
|
|
42
|
+
const clientCode = generate(options.input);
|
|
43
|
+
writeFileSync(options.output, clientCode, 'utf8');
|
|
44
|
+
|
|
45
|
+
console.log('✓ Fetch client generated successfully!');
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('Error generating client:', error.message);
|
|
48
|
+
if (!options.watch) {
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Generate initially
|
|
55
|
+
generateClient();
|
|
56
|
+
|
|
57
|
+
// If watch flag is set, watch for changes
|
|
58
|
+
if (options.watch) {
|
|
59
|
+
console.log(`Watching ${options.input} for changes...`);
|
|
60
|
+
|
|
61
|
+
const watcher = chokidar.watch(options.input, {
|
|
62
|
+
persistent: true,
|
|
63
|
+
ignoreInitial: true
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
watcher.on('change', () => {
|
|
67
|
+
console.log('\nFile changed, regenerating...');
|
|
68
|
+
generateClient();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
watcher.on('error', (error) => {
|
|
72
|
+
console.error('Watcher error:', error);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Keep the process alive
|
|
76
|
+
process.on('SIGINT', () => {
|
|
77
|
+
console.log('\nStopping file watcher...');
|
|
78
|
+
watcher.close();
|
|
79
|
+
process.exit(0);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
27
82
|
});
|
|
28
83
|
|
|
29
84
|
program.parse();
|
package/index.js
CHANGED
|
@@ -1,4 +1,141 @@
|
|
|
1
|
-
|
|
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
|
+
|
|
2
137
|
export default {
|
|
3
|
-
|
|
138
|
+
parseOpenAPISpec,
|
|
139
|
+
generateFetchClient,
|
|
140
|
+
generate
|
|
4
141
|
};
|
package/package.json
CHANGED
package/test/cli.test.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { expect } from 'chai';
|
|
2
|
-
import { exec } from 'child_process';
|
|
2
|
+
import { exec, spawn } from 'child_process';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
+
import { writeFileSync, unlinkSync, existsSync, readFileSync } from 'fs';
|
|
5
6
|
|
|
6
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
8
|
const __dirname = path.dirname(__filename);
|
|
@@ -23,4 +24,114 @@ describe('CLI', () => {
|
|
|
23
24
|
done();
|
|
24
25
|
});
|
|
25
26
|
});
|
|
27
|
+
|
|
28
|
+
describe('watch functionality', () => {
|
|
29
|
+
const testInputPath = path.join(__dirname, 'test-input.json');
|
|
30
|
+
const testOutputPath = path.join(__dirname, 'test-output.js');
|
|
31
|
+
const sampleSpec = {
|
|
32
|
+
"openapi": "3.0.1",
|
|
33
|
+
"info": { "title": "Watch Test API", "version": "1.0.0" },
|
|
34
|
+
"paths": {
|
|
35
|
+
"/test": {
|
|
36
|
+
"get": {
|
|
37
|
+
"operationId": "getTest",
|
|
38
|
+
"responses": { "200": { "description": "OK" } }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
writeFileSync(testInputPath, JSON.stringify(sampleSpec, null, 2));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
[testInputPath, testOutputPath].forEach(file => {
|
|
50
|
+
if (existsSync(file)) {
|
|
51
|
+
unlinkSync(file);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should regenerate client when input file changes', function(done) {
|
|
57
|
+
this.timeout(5000);
|
|
58
|
+
|
|
59
|
+
const cliPath = path.join(__dirname, '../bin/cli.js');
|
|
60
|
+
const child = spawn('node', [cliPath, 'generate', '-i', testInputPath, '-o', testOutputPath, '--watch'], {
|
|
61
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let outputReceived = false;
|
|
65
|
+
let regenerateReceived = false;
|
|
66
|
+
|
|
67
|
+
child.stdout.on('data', (data) => {
|
|
68
|
+
const output = data.toString();
|
|
69
|
+
|
|
70
|
+
if (output.includes('✓ Fetch client generated successfully!') && !outputReceived) {
|
|
71
|
+
outputReceived = true;
|
|
72
|
+
expect(existsSync(testOutputPath)).to.be.true;
|
|
73
|
+
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
const updatedSpec = { ...sampleSpec };
|
|
76
|
+
updatedSpec.info.title = "Updated Watch Test API";
|
|
77
|
+
writeFileSync(testInputPath, JSON.stringify(updatedSpec, null, 2));
|
|
78
|
+
}, 100);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (output.includes('File changed, regenerating...') && !regenerateReceived) {
|
|
82
|
+
regenerateReceived = true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (outputReceived && regenerateReceived && output.includes('✓ Fetch client generated successfully!')) {
|
|
86
|
+
const clientCode = readFileSync(testOutputPath, 'utf8');
|
|
87
|
+
expect(clientCode).to.include('class ApiClient');
|
|
88
|
+
child.kill('SIGINT');
|
|
89
|
+
done();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
child.stderr.on('data', (data) => {
|
|
94
|
+
console.error('CLI stderr:', data.toString());
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
child.on('error', (error) => {
|
|
98
|
+
done(error);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should handle watch mode with invalid input gracefully', function(done) {
|
|
103
|
+
this.timeout(3000);
|
|
104
|
+
|
|
105
|
+
const cliPath = path.join(__dirname, '../bin/cli.js');
|
|
106
|
+
const child = spawn('node', [cliPath, 'generate', '-i', testInputPath, '-o', testOutputPath, '--watch'], {
|
|
107
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
let initialGeneration = false;
|
|
111
|
+
|
|
112
|
+
child.stdout.on('data', (data) => {
|
|
113
|
+
const output = data.toString();
|
|
114
|
+
|
|
115
|
+
if (output.includes('✓ Fetch client generated successfully!') && !initialGeneration) {
|
|
116
|
+
initialGeneration = true;
|
|
117
|
+
|
|
118
|
+
setTimeout(() => {
|
|
119
|
+
writeFileSync(testInputPath, 'invalid json');
|
|
120
|
+
}, 100);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
child.stderr.on('data', (data) => {
|
|
125
|
+
const errorOutput = data.toString();
|
|
126
|
+
if (errorOutput.includes('Error generating client:')) {
|
|
127
|
+
child.kill('SIGINT');
|
|
128
|
+
done();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
child.on('error', (error) => {
|
|
133
|
+
done(error);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
26
137
|
});
|
package/test/index.test.js
CHANGED
|
@@ -1,8 +1,104 @@
|
|
|
1
1
|
import { expect } from 'chai';
|
|
2
|
-
import
|
|
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
|
+
};
|
|
3
55
|
|
|
4
56
|
describe('fetch-client-generator', () => {
|
|
5
|
-
|
|
6
|
-
|
|
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
|
+
});
|
|
7
103
|
});
|
|
8
104
|
});
|