Skip to content

Commit c8f9257

Browse files
authored
refactor: use openapi-fetch (#47)
1 parent 7156dd8 commit c8f9257

13 files changed

+246
-145
lines changed

package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"mongodb-log-writer": "^2.4.1",
6060
"mongodb-redact": "^1.1.6",
6161
"mongodb-schema": "^12.6.2",
62+
"openapi-fetch": "^0.13.5",
6263
"yargs-parser": "^21.1.1",
6364
"zod": "^3.24.2"
6465
},

scripts/filter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ function filterOpenapi(openapi: OpenAPIV3_1.Document): OpenAPIV3_1.Document {
2222
"getProject",
2323
"createProject",
2424
"listClusters",
25+
"getCluster",
2526
"createCluster",
2627
"listClustersForAllProjects",
2728
"createDatabaseUser",

src/common/atlas/apiClient.ts

Lines changed: 83 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
11
import config from "../../config.js";
2+
import createClient, { FetchOptions, Middleware } from "openapi-fetch";
23

3-
import {
4-
Group,
5-
PaginatedOrgGroupView,
6-
PaginatedAtlasGroupView,
7-
ClusterDescription20240805,
8-
PaginatedClusterDescription20240805,
9-
PaginatedNetworkAccessView,
10-
NetworkPermissionEntry,
11-
CloudDatabaseUser,
12-
PaginatedApiAtlasDatabaseUserView,
13-
} from "./openapi.js";
4+
import { paths, operations } from "./openapi.js";
145

156
export interface OAuthToken {
167
access_token: string;
@@ -40,6 +31,16 @@ export class ApiClientError extends Error {
4031
this.name = "ApiClientError";
4132
this.response = response;
4233
}
34+
35+
static async fromResponse(response: Response, message?: string): Promise<ApiClientError> {
36+
message ||= `error calling Atlas API`;
37+
try {
38+
const text = await response.text();
39+
return new ApiClientError(`${message}: [${response.status} ${response.statusText}] ${text}`, response);
40+
} catch {
41+
return new ApiClientError(`${message}: ${response.status} ${response.statusText}`, response);
42+
}
43+
}
4344
}
4445

4546
export interface ApiClientOptions {
@@ -48,32 +49,40 @@ export interface ApiClientOptions {
4849
}
4950

5051
export class ApiClient {
51-
token?: OAuthToken;
52-
saveToken?: saveTokenFunction;
52+
private token?: OAuthToken;
53+
private saveToken?: saveTokenFunction;
54+
private client = createClient<paths>({
55+
baseUrl: config.apiBaseUrl,
56+
headers: {
57+
"User-Agent": config.userAgent,
58+
Accept: `application/vnd.atlas.${config.atlasApiVersion}+json`,
59+
},
60+
});
61+
private authMiddleware = (apiClient: ApiClient): Middleware => ({
62+
async onRequest({ request, schemaPath }) {
63+
if (schemaPath.startsWith("/api/private/unauth") || schemaPath.startsWith("/api/oauth")) {
64+
return undefined;
65+
}
66+
if (await apiClient.validateToken()) {
67+
request.headers.set("Authorization", `Bearer ${apiClient.token?.access_token}`);
68+
return request;
69+
}
70+
},
71+
});
72+
private errorMiddleware = (): Middleware => ({
73+
async onResponse({ response }) {
74+
if (!response.ok) {
75+
throw await ApiClientError.fromResponse(response);
76+
}
77+
},
78+
});
5379

5480
constructor(options: ApiClientOptions) {
5581
const { token, saveToken } = options;
5682
this.token = token;
5783
this.saveToken = saveToken;
58-
}
59-
60-
private defaultOptions(): RequestInit {
61-
const authHeaders = !this.token?.access_token
62-
? null
63-
: {
64-
Authorization: `Bearer ${this.token.access_token}`,
65-
};
66-
67-
return {
68-
method: "GET",
69-
credentials: !this.token?.access_token ? undefined : "include",
70-
headers: {
71-
"Content-Type": "application/json",
72-
Accept: `application/vnd.atlas.${config.atlasApiVersion}+json`,
73-
"User-Agent": config.userAgent,
74-
...authHeaders,
75-
},
76-
};
84+
this.client.use(this.authMiddleware(this));
85+
this.client.use(this.errorMiddleware());
7786
}
7887

7988
async storeToken(token: OAuthToken): Promise<OAuthToken> {
@@ -86,36 +95,6 @@ export class ApiClient {
8695
return token;
8796
}
8897

89-
async do<T>(endpoint: string, options?: RequestInit): Promise<T> {
90-
if (!this.token || !this.token.access_token) {
91-
throw new Error("Not authenticated. Please run the auth tool first.");
92-
}
93-
94-
const url = new URL(`api/atlas/v2${endpoint}`, `${config.apiBaseUrl}`);
95-
96-
if (!this.checkTokenExpiry()) {
97-
await this.refreshToken();
98-
}
99-
100-
const defaultOpt = this.defaultOptions();
101-
const opt = {
102-
...defaultOpt,
103-
...options,
104-
headers: {
105-
...defaultOpt.headers,
106-
...options?.headers,
107-
},
108-
};
109-
110-
const response = await fetch(url, opt);
111-
112-
if (!response.ok) {
113-
throw new ApiClientError(`Error calling Atlas API: ${await response.text()}`, response);
114-
}
115-
116-
return (await response.json()) as T;
117-
}
118-
11998
async authenticate(): Promise<OauthDeviceCode> {
12099
const endpoint = "api/private/unauth/account/device/authorize";
121100

@@ -135,7 +114,7 @@ export class ApiClient {
135114
});
136115

137116
if (!response.ok) {
138-
throw new ApiClientError(`Failed to initiate authentication: ${response.statusText}`, response);
117+
throw await ApiClientError.fromResponse(response, `failed to initiate authentication`);
139118
}
140119

141120
return (await response.json()) as OauthDeviceCode;
@@ -166,14 +145,18 @@ export class ApiClient {
166145
try {
167146
const errorResponse = await response.json();
168147
if (errorResponse.errorCode === "DEVICE_AUTHORIZATION_PENDING") {
169-
throw new ApiClientError("Authentication pending. Try again later.", response);
170-
} else if (errorResponse.error === "expired_token") {
171-
throw new ApiClientError("Device code expired. Please restart the authentication process.", response);
148+
throw await ApiClientError.fromResponse(response, "Authentication pending. Try again later.");
172149
} else {
173-
throw new ApiClientError("Device code expired. Please restart the authentication process.", response);
150+
throw await ApiClientError.fromResponse(
151+
response,
152+
"Device code expired. Please restart the authentication process."
153+
);
174154
}
175155
} catch {
176-
throw new ApiClientError("Failed to retrieve token. Please check your device code.", response);
156+
throw await ApiClientError.fromResponse(
157+
response,
158+
"Failed to retrieve token. Please check your device code."
159+
);
177160
}
178161
}
179162

@@ -195,7 +178,7 @@ export class ApiClient {
195178
});
196179

197180
if (!response.ok) {
198-
throw new ApiClientError(`Failed to refresh token: ${response.statusText}`, response);
181+
throw await ApiClientError.fromResponse(response, "Failed to refresh token");
199182
}
200183
const data = await response.json();
201184

@@ -229,7 +212,7 @@ export class ApiClient {
229212
});
230213

231214
if (!response.ok) {
232-
throw new ApiClientError(`Failed to revoke token: ${response.statusText}`, response);
215+
throw await ApiClientError.fromResponse(response);
233216
}
234217

235218
if (!token && this.token) {
@@ -269,58 +252,53 @@ export class ApiClient {
269252
}
270253
}
271254

272-
async listProjects(): Promise<PaginatedAtlasGroupView> {
273-
return await this.do<PaginatedAtlasGroupView>("/groups");
255+
async listProjects(options?: FetchOptions<operations["listProjects"]>) {
256+
const { data } = await this.client.GET(`/api/atlas/v2/groups`, options);
257+
return data;
274258
}
275259

276-
async listProjectIpAccessLists(groupId: string): Promise<PaginatedNetworkAccessView> {
277-
return await this.do<PaginatedNetworkAccessView>(`/groups/${groupId}/accessList`);
260+
async listProjectIpAccessLists(options: FetchOptions<operations["listProjectIpAccessLists"]>) {
261+
const { data } = await this.client.GET(`/api/atlas/v2/groups/{groupId}/accessList`, options);
262+
return data;
278263
}
279264

280-
async createProjectIpAccessList(
281-
groupId: string,
282-
entries: NetworkPermissionEntry[]
283-
): Promise<PaginatedNetworkAccessView> {
284-
return await this.do<PaginatedNetworkAccessView>(`/groups/${groupId}/accessList`, {
285-
method: "POST",
286-
body: JSON.stringify(entries),
287-
});
265+
async createProjectIpAccessList(options: FetchOptions<operations["createProjectIpAccessList"]>) {
266+
const { data } = await this.client.POST(`/api/atlas/v2/groups/{groupId}/accessList`, options);
267+
return data;
288268
}
289269

290-
async getProject(groupId: string): Promise<Group> {
291-
return await this.do<Group>(`/groups/${groupId}`);
270+
async getProject(options: FetchOptions<operations["getProject"]>) {
271+
const { data } = await this.client.GET(`/api/atlas/v2/groups/{groupId}`, options);
272+
return data;
292273
}
293274

294-
async listClusters(groupId: string): Promise<PaginatedClusterDescription20240805> {
295-
return await this.do<PaginatedClusterDescription20240805>(`/groups/${groupId}/clusters`);
275+
async listClusters(options: FetchOptions<operations["listClusters"]>) {
276+
const { data } = await this.client.GET(`/api/atlas/v2/groups/{groupId}/clusters`, options);
277+
return data;
296278
}
297279

298-
async listClustersForAllProjects(): Promise<PaginatedOrgGroupView> {
299-
return await this.do<PaginatedOrgGroupView>(`/clusters`);
280+
async listClustersForAllProjects(options?: FetchOptions<operations["listClustersForAllProjects"]>) {
281+
const { data } = await this.client.GET(`/api/atlas/v2/clusters`, options);
282+
return data;
300283
}
301284

302-
async getCluster(groupId: string, clusterName: string): Promise<ClusterDescription20240805> {
303-
return await this.do<ClusterDescription20240805>(`/groups/${groupId}/clusters/${clusterName}`);
285+
async getCluster(options: FetchOptions<operations["getCluster"]>) {
286+
const { data } = await this.client.GET(`/api/atlas/v2/groups/{groupId}/clusters/{clusterName}`, options);
287+
return data;
304288
}
305289

306-
async createCluster(groupId: string, cluster: ClusterDescription20240805): Promise<ClusterDescription20240805> {
307-
if (!cluster.groupId) {
308-
throw new Error("Cluster groupId is required");
309-
}
310-
return await this.do<ClusterDescription20240805>(`/groups/${groupId}/clusters`, {
311-
method: "POST",
312-
body: JSON.stringify(cluster),
313-
});
290+
async createCluster(options: FetchOptions<operations["createCluster"]>) {
291+
const { data } = await this.client.POST("/api/atlas/v2/groups/{groupId}/clusters", options);
292+
return data;
314293
}
315294

316-
async createDatabaseUser(groupId: string, user: CloudDatabaseUser): Promise<CloudDatabaseUser> {
317-
return await this.do<CloudDatabaseUser>(`/groups/${groupId}/databaseUsers`, {
318-
method: "POST",
319-
body: JSON.stringify(user),
320-
});
295+
async createDatabaseUser(options: FetchOptions<operations["createDatabaseUser"]>) {
296+
const { data } = await this.client.POST("/api/atlas/v2/groups/{groupId}/databaseUsers", options);
297+
return data;
321298
}
322299

323-
async listDatabaseUsers(groupId: string): Promise<PaginatedApiAtlasDatabaseUserView> {
324-
return await this.do<PaginatedApiAtlasDatabaseUserView>(`/groups/${groupId}/databaseUsers`);
300+
async listDatabaseUsers(options: FetchOptions<operations["listDatabaseUsers"]>) {
301+
const { data } = await this.client.GET(`/api/atlas/v2/groups/{groupId}/databaseUsers`, options);
302+
return data;
325303
}
326304
}

src/common/atlas/openapi.d.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,28 @@ export interface paths {
120120
patch?: never;
121121
trace?: never;
122122
};
123+
"/api/atlas/v2/groups/{groupId}/clusters/{clusterName}": {
124+
parameters: {
125+
query?: never;
126+
header?: never;
127+
path?: never;
128+
cookie?: never;
129+
};
130+
/**
131+
* Return One Cluster from One Project
132+
* @description Returns the details for one cluster in the specified project. Clusters contain a group of hosts that maintain the same data set. The response includes clusters with asymmetrically-sized shards. To use this resource, the requesting Service Account or API Key must have the Project Read Only role. This feature is not available for serverless clusters.
133+
*
134+
* This endpoint can also be used on Flex clusters that were created using the [createCluster](https://www.mongodb.com/docs/atlas/reference/api-resources-spec/v2/#tag/Clusters/operation/createCluster) endpoint or former M2/M5 clusters that have been migrated to Flex clusters until January 2026. Please use the getFlexCluster endpoint for Flex clusters instead. Deprecated versions: v2-{2023-02-01}, v2-{2023-01-01}
135+
*/
136+
get: operations["getCluster"];
137+
put?: never;
138+
post?: never;
139+
delete?: never;
140+
options?: never;
141+
head?: never;
142+
patch?: never;
143+
trace?: never;
144+
};
123145
"/api/atlas/v2/groups/{groupId}/databaseUsers": {
124146
parameters: {
125147
query?: never;
@@ -7467,6 +7489,42 @@ export interface operations {
74677489
500: components["responses"]["internalServerError"];
74687490
};
74697491
};
7492+
getCluster: {
7493+
parameters: {
7494+
query?: {
7495+
/** @description Flag that indicates whether Application wraps the response in an `envelope` JSON object. Some API clients cannot access the HTTP response headers or status code. To remediate this, set envelope=true in the query. Endpoints that return a list of results use the results object as an envelope. Application adds the status parameter to the response body. */
7496+
envelope?: components["parameters"]["envelope"];
7497+
/** @description Flag that indicates whether the response body should be in the prettyprint format. */
7498+
pretty?: components["parameters"]["pretty"];
7499+
};
7500+
header?: never;
7501+
path: {
7502+
/** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access.
7503+
*
7504+
* **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */
7505+
groupId: components["parameters"]["groupId"];
7506+
/** @description Human-readable label that identifies this cluster. */
7507+
clusterName: string;
7508+
};
7509+
cookie?: never;
7510+
};
7511+
requestBody?: never;
7512+
responses: {
7513+
/** @description OK */
7514+
200: {
7515+
headers: {
7516+
[name: string]: unknown;
7517+
};
7518+
content: {
7519+
"application/vnd.atlas.2024-08-05+json": components["schemas"]["ClusterDescription20240805"];
7520+
};
7521+
};
7522+
401: components["responses"]["unauthorized"];
7523+
404: components["responses"]["notFound"];
7524+
409: components["responses"]["conflict"];
7525+
500: components["responses"]["internalServerError"];
7526+
};
7527+
};
74707528
listDatabaseUsers: {
74717529
parameters: {
74727530
query?: {

0 commit comments

Comments
 (0)