Skip to content

Commit 59038b3

Browse files
jeffmauryfeloy
andauthored
feat: start REST server (#1544)
* feat: start REST server Signed-off-by: Philippe Martin <[email protected]> * fix: removed express-openapi-validator Signed-off-by: Jeff MAURY <[email protected]> Co-authored-by: Philippe Martin <[email protected]>
1 parent ba27749 commit 59038b3

File tree

9 files changed

+1017
-201
lines changed

9 files changed

+1017
-201
lines changed

Containerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ COPY packages/backend/media/ /extension/media
2222
COPY LICENSE /extension/
2323
COPY packages/backend/icon.png /extension/
2424
COPY README.md /extension/
25-
25+
COPY api/openapi.yaml /extension/api/
2626

2727
FROM scratch
2828

api/openapi.yaml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Podman Desktop AI Lab API
4+
description: API for interacting with the Podman Desktop AI Lab service.
5+
version: 0.0.1
6+
servers:
7+
- url: http://{host}:{port}
8+
description: Podman Desktop AI Lab API server
9+
variables:
10+
host:
11+
default: 127.0.0.1
12+
port:
13+
default: '10434'
14+
15+
tags:
16+
- name: server
17+
description: Server information
18+
19+
paths:
20+
/api/version:
21+
get:
22+
operationId: getServerVersion
23+
tags:
24+
- server
25+
description: Return the Podman Desktop AI Lab API server version
26+
summary: Return the Podman Desktop AI Lab API server version
27+
responses:
28+
'200':
29+
description: The Podman Desktop AI Lab API server version was successfully fetched
30+
content:
31+
application/json:
32+
schema:
33+
type: object
34+
additionalProperties: false
35+
properties:
36+
version:
37+
type: string
38+
required:
39+
- version

packages/backend/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
},
6262
"dependencies": {
6363
"@huggingface/gguf": "^0.1.9",
64+
"express": "^4.19.2",
6465
"isomorphic-git": "^1.27.1",
6566
"mustache": "^4.2.0",
6667
"openai": "^4.56.0",
@@ -72,10 +73,13 @@
7273
},
7374
"devDependencies": {
7475
"@podman-desktop/api": "1.12.0",
76+
"@types/express": "^4.17.21",
7577
"@types/js-yaml": "^4.0.9",
7678
"@types/mustache": "^4.2.5",
7779
"@types/node": "^20",
7880
"@types/postman-collection": "^3.5.10",
81+
"@types/supertest": "^6.0.2",
82+
"supertest": "^7.0.0",
7983
"vitest": "^2.0.5"
8084
}
8185
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**********************************************************************
2+
* Copyright (C) 2024 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
20+
import { ApiServer } from './apiServer';
21+
import request from 'supertest';
22+
import type * as podmanDesktopApi from '@podman-desktop/api';
23+
import path from 'path';
24+
import type { Server } from 'http';
25+
26+
class TestApiServer extends ApiServer {
27+
public override getListener(): Server | undefined {
28+
return super.getListener();
29+
}
30+
}
31+
32+
const extensionContext = {} as unknown as podmanDesktopApi.ExtensionContext;
33+
34+
let server: TestApiServer;
35+
36+
beforeEach(async () => {
37+
server = new TestApiServer(extensionContext);
38+
vi.spyOn(server, 'displayApiInfo').mockReturnValue();
39+
vi.spyOn(server, 'getSpecFile').mockReturnValue(path.join(__dirname, '../../../../api/openapi.yaml'));
40+
vi.spyOn(server, 'getPackageFile').mockReturnValue(path.join(__dirname, '../../../../package.json'));
41+
await server.init();
42+
});
43+
44+
afterEach(() => {
45+
server.dispose();
46+
});
47+
48+
test('/spec endpoint', async () => {
49+
expect(server.getListener()).toBeDefined();
50+
const res = await request(server.getListener()!)
51+
.get('/spec')
52+
.expect(200)
53+
.expect('Content-Type', 'application/yaml; charset=utf-8');
54+
expect(res.text).toMatch(/^openapi:/);
55+
});
56+
57+
test('/spec endpoint when spec file is not found', async () => {
58+
expect(server.getListener()).toBeDefined();
59+
vi.spyOn(server, 'getSpecFile').mockReturnValue(path.join(__dirname, '../../../../api/openapi-notfound.yaml'));
60+
const res = await request(server.getListener()!).get('/spec').expect(500);
61+
expect(res.body.message).toEqual('unable to get spec');
62+
});
63+
64+
test('/spec endpoint when getting spec file fails', async () => {
65+
expect(server.getListener()).toBeDefined();
66+
vi.spyOn(server, 'getSpecFile').mockImplementation(() => {
67+
throw 'an error getting spec file';
68+
});
69+
const res = await request(server.getListener()!).get('/spec').expect(500);
70+
expect(res.body.message).toEqual('unable to get spec');
71+
expect(res.body.errors[0]).toEqual('an error getting spec file');
72+
});
73+
74+
test('/api/version endpoint', async () => {
75+
expect(server.getListener()).toBeDefined();
76+
const res = await request(server.getListener()!)
77+
.get('/api/version')
78+
.expect(200)
79+
.expect('Content-Type', 'application/json; charset=utf-8');
80+
expect(res.body.version).toBeDefined();
81+
});
82+
83+
test('/api/version endpoint when package.json file is not found', async () => {
84+
expect(server.getListener()).toBeDefined();
85+
vi.spyOn(server, 'getPackageFile').mockReturnValue(path.join(__dirname, '../../../../package-notfound.json'));
86+
const res = await request(server.getListener()!).get('/api/version').expect(500);
87+
expect(res.body.message).toEqual('unable to get version');
88+
});
89+
90+
test('/api/version endpoint when getting package.json file fails', async () => {
91+
expect(server.getListener()).toBeDefined();
92+
vi.spyOn(server, 'getPackageFile').mockImplementation(() => {
93+
throw 'an error getting package file';
94+
});
95+
const res = await request(server.getListener()!).get('/api/version').expect(500);
96+
expect(res.body.message).toEqual('unable to get version');
97+
expect(res.body.errors[0]).toEqual('an error getting package file');
98+
});
99+
100+
test('/api/wrongEndpoint', async () => {
101+
expect(server.getListener()).toBeDefined();
102+
await request(server.getListener()!).get('/api/wrongEndpoint').expect(404);
103+
});
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**********************************************************************
2+
* Copyright (C) 2024 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import type { Disposable } from '@podman-desktop/api';
20+
import type { Request, Response } from 'express';
21+
import express from 'express';
22+
import type { Server } from 'http';
23+
import path from 'node:path';
24+
import http from 'node:http';
25+
import { existsSync } from 'fs';
26+
import { getFreeRandomPort } from '../utils/ports';
27+
import * as podmanDesktopApi from '@podman-desktop/api';
28+
import { readFile } from 'fs/promises';
29+
30+
const DEFAULT_PORT = 10434;
31+
const SHOW_API_INFO_COMMAND = 'ai-lab.show-api-info';
32+
33+
export class ApiServer implements Disposable {
34+
#listener?: Server;
35+
36+
constructor(private extensionContext: podmanDesktopApi.ExtensionContext) {}
37+
38+
protected getListener(): Server | undefined {
39+
return this.#listener;
40+
}
41+
42+
async init(): Promise<void> {
43+
const app = express();
44+
45+
const router = express.Router();
46+
router.use(express.json());
47+
48+
// declare routes
49+
router.get('/version', this.getVersion.bind(this));
50+
app.use('/api', router);
51+
app.use('/spec', this.getSpec.bind(this));
52+
53+
const server = http.createServer(app);
54+
let listeningOn = DEFAULT_PORT;
55+
server.on('listening', () => {
56+
this.displayApiInfo(listeningOn);
57+
});
58+
server.on('error', () => {
59+
getFreeRandomPort('0.0.0.0')
60+
.then((randomPort: number) => {
61+
console.warn(`port ${DEFAULT_PORT} in use, using ${randomPort} for API server`);
62+
listeningOn = randomPort;
63+
this.#listener = server.listen(randomPort);
64+
})
65+
.catch((e: unknown) => {
66+
console.error('unable to get a free port for the api server', e);
67+
});
68+
});
69+
this.#listener = server.listen(DEFAULT_PORT);
70+
}
71+
72+
displayApiInfo(port: number): void {
73+
const apiStatusBarItem = podmanDesktopApi.window.createStatusBarItem();
74+
apiStatusBarItem.text = `AI Lab API listening on port ${port}`;
75+
apiStatusBarItem.command = SHOW_API_INFO_COMMAND;
76+
this.extensionContext.subscriptions.push(
77+
podmanDesktopApi.commands.registerCommand(SHOW_API_INFO_COMMAND, async () => {
78+
const address = `http://localhost:${port}`;
79+
const result = await podmanDesktopApi.window.showInformationMessage(
80+
`AI Lab API is listening on\n${address}`,
81+
'OK',
82+
`Copy`,
83+
);
84+
if (result === 'Copy') {
85+
await podmanDesktopApi.env.clipboard.writeText(address);
86+
}
87+
}),
88+
apiStatusBarItem,
89+
);
90+
apiStatusBarItem.show();
91+
}
92+
93+
private getFile(filepath: string): string {
94+
// when plugin is installed, the file is placed in the plugin directory (~/.local/share/containers/podman-desktop/plugins/<pluginname>/)
95+
const prodFile = path.join(__dirname, filepath);
96+
if (existsSync(prodFile)) {
97+
return prodFile;
98+
}
99+
// return dev file
100+
return path.join(__dirname, '..', '..', filepath);
101+
}
102+
103+
getSpecFile(): string {
104+
return this.getFile('../api/openapi.yaml');
105+
}
106+
107+
getPackageFile(): string {
108+
return this.getFile('../package.json');
109+
}
110+
111+
dispose(): void {
112+
this.#listener?.close();
113+
}
114+
115+
getSpec(_req: Request, res: Response): void {
116+
const doErr = (err: unknown) => {
117+
res.status(500).json({
118+
message: 'unable to get spec',
119+
errors: [err],
120+
});
121+
};
122+
try {
123+
const spec = this.getSpecFile();
124+
readFile(spec, 'utf-8')
125+
.then(content => {
126+
res.status(200).type('application/yaml').send(content);
127+
})
128+
.catch((err: unknown) => doErr(err));
129+
} catch (err: unknown) {
130+
doErr(err);
131+
}
132+
}
133+
134+
getVersion(_req: Request, res: Response): void {
135+
const doErr = (err: unknown) => {
136+
res.status(500).json({
137+
message: 'unable to get version',
138+
errors: [err],
139+
});
140+
};
141+
try {
142+
const pkg = this.getPackageFile();
143+
readFile(pkg, 'utf-8')
144+
.then(content => {
145+
const json = JSON.parse(content);
146+
res.status(200).json({ version: `v${json.version}` });
147+
})
148+
.catch((err: unknown) => doErr(err));
149+
} catch (err: unknown) {
150+
doErr(err);
151+
}
152+
}
153+
}

packages/backend/src/studio.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ vi.mock('@podman-desktop/api', async () => {
7777
},
7878
onDidChangeViewState: vi.fn(),
7979
}),
80+
createStatusBarItem: () => ({
81+
show: vi.fn(),
82+
}),
8083
},
8184
env: {
8285
createTelemetryLogger: () => ({

packages/backend/src/studio.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { ConfigurationRegistry } from './registries/ConfigurationRegistry';
4848
import { RecipeManager } from './managers/recipes/RecipeManager';
4949
import { GPUManager } from './managers/GPUManager';
5050
import { WhisperCpp } from './workers/provider/WhisperCpp';
51+
import { ApiServer } from './managers/apiServer';
5152

5253
export class Studio {
5354
readonly #extensionContext: ExtensionContext;
@@ -326,6 +327,10 @@ export class Studio {
326327
);
327328
// Register the instance
328329
this.#rpcExtension.registerInstance<StudioApiImpl>(StudioApiImpl, this.#studioApi);
330+
331+
const apiServer = new ApiServer(this.#extensionContext);
332+
await apiServer.init();
333+
this.#extensionContext.subscriptions.push(apiServer);
329334
}
330335

331336
public async deactivate(): Promise<void> {

packages/backend/vite.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const config = {
3535
'/@gen/': join(PACKAGE_ROOT, 'src-generated') + '/',
3636
'@shared/': join(PACKAGE_ROOT, '../shared') + '/',
3737
},
38+
mainFields: ['module', 'jsnext:main', 'jsnext'], //https://github.com/vitejs/vite/issues/16444
3839
},
3940
build: {
4041
sourcemap: 'inline',

0 commit comments

Comments
 (0)